🧬 refactor: Wire Database Methods into MCP Package via Registry Pattern (#10715)

* Refactor: MCPServersRegistry Singleton Pattern with Dependency Injection for DB methods consumption

* refactor: error handling in MCP initialization and improve logging for MCPServersRegistry instance creation.

- Added checks for mongoose instance in ServerConfigsDB constructor and refined error messages for clarity.
- Reorder and use type imports

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Atef Bellaaj 2025-12-01 00:57:46 +01:00 committed by Danny Avila
parent da473bf43a
commit ad6ba4b6d1
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
24 changed files with 328 additions and 150 deletions

View file

@ -1,7 +1,7 @@
import { logger } from '@librechat/data-schemas';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { MCPConnection } from './connection';
import { mcpServersRegistry as registry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import type * as t from './types';
/**
@ -25,7 +25,7 @@ export class ConnectionsRepository {
/** Checks whether this repository can connect to a specific server */
async has(serverName: string): Promise<boolean> {
const config = await registry.getServerConfig(serverName, this.ownerId);
const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, this.ownerId);
const canConnect = !!config && this.isAllowedToConnectToServer(config);
if (!canConnect) {
//if connection is no longer possible we attempt to disconnect any leftover connections
@ -36,7 +36,10 @@ export class ConnectionsRepository {
/** Gets or creates a connection for the specified server with lazy loading */
async get(serverName: string): Promise<MCPConnection | null> {
const serverConfig = await registry.getServerConfig(serverName, this.ownerId);
const serverConfig = await MCPServersRegistry.getInstance().getServerConfig(
serverName,
this.ownerId,
);
const existingConnection = this.connections.get(serverName);
if (!serverConfig || !this.isAllowedToConnectToServer(serverConfig)) {
@ -94,7 +97,7 @@ export class ConnectionsRepository {
async getAll(): Promise<Map<string, MCPConnection>> {
//TODO in the future we should use a scoped config getter (APPLevel, UserLevel, Private)
//for now the absent config will not throw error
const allConfigs = await registry.getAllServerConfigs(this.ownerId);
const allConfigs = await MCPServersRegistry.getInstance().getAllServerConfigs(this.ownerId);
return this.getMany(Object.keys(allConfigs));
}

View file

@ -11,7 +11,7 @@ import { UserConnectionManager } from './UserConnectionManager';
import { ConnectionsRepository } from './ConnectionsRepository';
import { MCPServerInspector } from './registry/MCPServerInspector';
import { MCPServersInitializer } from './registry/MCPServersInitializer';
import { mcpServersRegistry as registry } from './registry/MCPServersRegistry';
import { MCPServersRegistry } from './registry/MCPServersRegistry';
import { formatToolContent } from './parsers';
import { MCPConnection } from './connection';
import { processMCPEnv } from '~/utils/env';
@ -69,7 +69,7 @@ export class MCPManager extends UserConnectionManager {
/** Returns all available tool functions from app-level connections */
public async getAppToolFunctions(): Promise<t.LCAvailableTools> {
const toolFunctions: t.LCAvailableTools = {};
const configs = await registry.getAllServerConfigs();
const configs = await MCPServersRegistry.getInstance().getAllServerConfigs();
for (const config of Object.values(configs)) {
if (config.toolFunctions != null) {
Object.assign(toolFunctions, config.toolFunctions);
@ -115,7 +115,7 @@ export class MCPManager extends UserConnectionManager {
*/
private async getInstructions(serverNames?: string[]): Promise<Record<string, string>> {
const instructions: Record<string, string> = {};
const configs = await registry.getAllServerConfigs();
const configs = await MCPServersRegistry.getInstance().getAllServerConfigs();
for (const [serverName, config] of Object.entries(configs)) {
if (config.serverInstructions != null) {
instructions[serverName] = config.serverInstructions as string;
@ -216,7 +216,10 @@ Please follow these instructions when using tools from the respective MCP server
);
}
const rawConfig = (await registry.getServerConfig(serverName, userId)) as t.MCPOptions;
const rawConfig = (await MCPServersRegistry.getInstance().getServerConfig(
serverName,
userId,
)) as t.MCPOptions;
const currentOptions = processMCPEnv({
user,
options: rawConfig,

View file

@ -1,7 +1,7 @@
import { logger } from '@librechat/data-schemas';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { mcpServersRegistry as serversRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPConnection } from './connection';
import type * as t from './types';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
@ -61,7 +61,7 @@ export abstract class UserConnectionManager {
);
}
const config = await serversRegistry.getServerConfig(serverName, userId);
const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId);
const userServerMap = this.userConnections.get(userId);
let connection = forceNew ? undefined : userServerMap?.get(serverName);

View file

@ -21,18 +21,21 @@ jest.mock('../MCPConnectionFactory', () => ({
jest.mock('../connection');
// Mock the registry
const mockRegistryInstance = {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
};
jest.mock('../registry/MCPServersRegistry', () => ({
mcpServersRegistry: {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
MCPServersRegistry: {
getInstance: () => mockRegistryInstance,
},
}));
const mockLogger = logger as jest.Mocked<typeof logger>;
// Import mocked registry
import { mcpServersRegistry as registry } from '../registry/MCPServersRegistry';
const mockRegistry = registry as jest.Mocked<typeof registry>;
// Use mocked registry instance
const mockRegistry = mockRegistryInstance as jest.Mocked<typeof mockRegistryInstance>;
describe('ConnectionsRepository', () => {
let repository: ConnectionsRepository;

View file

@ -1,7 +1,6 @@
import { logger } from '@librechat/data-schemas';
import type * as t from '~/mcp/types';
import { MCPManager } from '~/mcp/MCPManager';
import { mcpServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer';
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
@ -17,11 +16,15 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
const mockRegistryInstance = {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
getOAuthServers: jest.fn(),
};
jest.mock('~/mcp/registry/MCPServersRegistry', () => ({
mcpServersRegistry: {
getServerConfig: jest.fn(),
getAllServerConfigs: jest.fn(),
getOAuthServers: jest.fn(),
MCPServersRegistry: {
getInstance: () => mockRegistryInstance,
},
}));
@ -47,7 +50,7 @@ describe('MCPManager', () => {
// Set up default mock implementations
(MCPServersInitializer.initialize as jest.Mock).mockResolvedValue(undefined);
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({});
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({});
});
function mockAppConnections(
@ -75,7 +78,7 @@ describe('MCPManager', () => {
describe('getAppToolFunctions', () => {
it('should return empty object when no servers have tool functions', async () => {
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: { type: 'stdio', command: 'test', args: [] },
server2: { type: 'stdio', command: 'test2', args: [] },
});
@ -109,7 +112,7 @@ describe('MCPManager', () => {
},
};
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: {
type: 'stdio',
command: 'test',
@ -145,7 +148,7 @@ describe('MCPManager', () => {
},
};
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: {
type: 'stdio',
command: 'test',
@ -174,7 +177,7 @@ describe('MCPManager', () => {
describe('formatInstructionsForContext', () => {
it('should return empty string when no servers have instructions', async () => {
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
server1: { type: 'stdio', command: 'test', args: [] },
server2: { type: 'stdio', command: 'test2', args: [] },
});
@ -186,7 +189,7 @@ describe('MCPManager', () => {
});
it('should format instructions from multiple servers', async () => {
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
@ -211,7 +214,7 @@ describe('MCPManager', () => {
});
it('should filter instructions by server names when provided', async () => {
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
@ -243,7 +246,7 @@ describe('MCPManager', () => {
});
it('should handle servers with null or undefined instructions', async () => {
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',
@ -272,7 +275,7 @@ describe('MCPManager', () => {
});
it('should return empty string when filtered servers have no instructions', async () => {
(mcpServersRegistry.getAllServerConfigs as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getAllServerConfigs as jest.Mock).mockResolvedValue({
github: {
type: 'sse',
url: 'https://api.github.com',

View file

@ -1,7 +1,6 @@
import { TokenMethods } from '@librechat/data-schemas';
import { FlowStateManager, MCPConnection, MCPOAuthTokens, MCPOptions } from '../..';
import { MCPManager } from '../MCPManager';
import { mcpServersRegistry } from '../../mcp/registry/MCPServersRegistry';
import { OAuthReconnectionManager } from './OAuthReconnectionManager';
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
@ -14,11 +13,15 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
const mockRegistryInstance = {
getServerConfig: jest.fn(),
getOAuthServers: jest.fn(),
};
jest.mock('../MCPManager');
jest.mock('../../mcp/registry/MCPServersRegistry', () => ({
mcpServersRegistry: {
getServerConfig: jest.fn(),
getOAuthServers: jest.fn(),
MCPServersRegistry: {
getInstance: () => mockRegistryInstance,
},
}));
@ -61,7 +64,7 @@ describe('OAuthReconnectionManager', () => {
} as unknown as jest.Mocked<MCPManager>;
(MCPManager.getInstance as jest.Mock).mockReturnValue(mockMCPManager);
(mcpServersRegistry.getServerConfig as jest.Mock).mockResolvedValue({});
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({});
});
afterEach(() => {
@ -159,7 +162,7 @@ describe('OAuthReconnectionManager', () => {
it('should reconnect eligible servers', async () => {
const userId = 'user-123';
const oauthServers = new Set(['server1', 'server2', 'server3']);
(mcpServersRegistry.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
(mockRegistryInstance.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
// server1: has failed reconnection
reconnectionTracker.setFailed(userId, 'server1');
@ -193,7 +196,7 @@ describe('OAuthReconnectionManager', () => {
mockMCPManager.getUserConnection.mockResolvedValue(
mockNewConnection as unknown as MCPConnection,
);
(mcpServersRegistry.getServerConfig as jest.Mock).mockResolvedValue({
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({
initTimeout: 5000,
} as unknown as MCPOptions);
@ -224,7 +227,7 @@ describe('OAuthReconnectionManager', () => {
it('should handle failed reconnection attempts', async () => {
const userId = 'user-123';
const oauthServers = new Set(['server1']);
(mcpServersRegistry.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
(mockRegistryInstance.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
// server1: has valid token
tokenMethods.findToken.mockResolvedValue({
@ -235,7 +238,7 @@ describe('OAuthReconnectionManager', () => {
// Mock failed connection
mockMCPManager.getUserConnection.mockRejectedValue(new Error('Connection failed'));
(mcpServersRegistry.getServerConfig as jest.Mock).mockResolvedValue(
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(
{} as unknown as MCPOptions,
);
@ -253,7 +256,7 @@ describe('OAuthReconnectionManager', () => {
it('should not reconnect servers with expired tokens', async () => {
const userId = 'user-123';
const oauthServers = new Set(['server1']);
(mcpServersRegistry.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
(mockRegistryInstance.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
// server1: has expired token
tokenMethods.findToken.mockResolvedValue({
@ -272,7 +275,7 @@ describe('OAuthReconnectionManager', () => {
it('should handle connection that returns but is not connected', async () => {
const userId = 'user-123';
const oauthServers = new Set(['server1']);
(mcpServersRegistry.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
(mockRegistryInstance.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
tokenMethods.findToken.mockResolvedValue({
userId,
@ -288,7 +291,7 @@ describe('OAuthReconnectionManager', () => {
mockMCPManager.getUserConnection.mockResolvedValue(
mockConnection as unknown as MCPConnection,
);
(mcpServersRegistry.getServerConfig as jest.Mock).mockResolvedValue(
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(
{} as unknown as MCPOptions,
);
@ -372,7 +375,7 @@ describe('OAuthReconnectionManager', () => {
it('should not attempt to reconnect servers that have timed out during reconnection', async () => {
const userId = 'user-123';
const oauthServers = new Set(['server1', 'server2']);
(mcpServersRegistry.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
(mockRegistryInstance.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
const now = Date.now();
jest.setSystemTime(now);
@ -427,7 +430,7 @@ describe('OAuthReconnectionManager', () => {
const userId = 'user-123';
const serverName = 'server1';
const oauthServers = new Set([serverName]);
(mcpServersRegistry.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
(mockRegistryInstance.getOAuthServers as jest.Mock).mockResolvedValue(oauthServers);
const now = Date.now();
jest.setSystemTime(now);
@ -441,7 +444,7 @@ describe('OAuthReconnectionManager', () => {
// First reconnect attempt - will fail
mockMCPManager.getUserConnection.mockRejectedValueOnce(new Error('Connection failed'));
(mcpServersRegistry.getServerConfig as jest.Mock).mockResolvedValue(
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue(
{} as unknown as MCPOptions,
);

View file

@ -4,7 +4,7 @@ import type { MCPOAuthTokens } from './types';
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
import { FlowStateManager } from '~/flow/manager';
import { MCPManager } from '~/mcp/MCPManager';
import { mcpServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000; // ms
@ -72,7 +72,7 @@ export class OAuthReconnectionManager {
// 1. derive the servers to reconnect
const serversToReconnect = [];
for (const serverName of await mcpServersRegistry.getOAuthServers()) {
for (const serverName of await MCPServersRegistry.getInstance().getOAuthServers()) {
const canReconnect = await this.canReconnect(userId, serverName);
if (canReconnect) {
serversToReconnect.push(serverName);
@ -104,7 +104,7 @@ export class OAuthReconnectionManager {
logger.info(`${logPrefix} Attempting reconnection`);
const config = await mcpServersRegistry.getServerConfig(serverName, userId);
const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId);
const cleanupOnFailedReconnect = () => {
this.reconnectionsTracker.setFailed(userId, serverName);

View file

@ -2,9 +2,9 @@ import { Constants } from 'librechat-data-provider';
import type { JsonSchemaType } from '@librechat/data-schemas';
import type { MCPConnection } from '~/mcp/connection';
import type * as t from '~/mcp/types';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { detectOAuthRequirement } from '~/mcp/oauth';
import { isEnabled } from '~/utils';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
/**
* Inspects MCP servers to discover their metadata, capabilities, and tools.

View file

@ -5,7 +5,7 @@ import { logger } from '@librechat/data-schemas';
import { ParsedServerConfig } from '~/mcp/types';
import { sanitizeUrlForLogging } from '~/mcp/utils';
import type * as t from '~/mcp/types';
import { mcpServersRegistry as registry } from './MCPServersRegistry';
import { MCPServersRegistry } from './MCPServersRegistry';
const MCP_INIT_TIMEOUT_MS =
process.env.MCP_INIT_TIMEOUT_MS != null ? parseInt(process.env.MCP_INIT_TIMEOUT_MS) : 30_000;
@ -36,7 +36,7 @@ export class MCPServersInitializer {
if (await isLeader()) {
// Leader performs initialization
await statusCache.reset();
await registry.reset();
await MCPServersRegistry.getInstance().reset();
const serverNames = Object.keys(rawConfigs);
await Promise.allSettled(
serverNames.map((serverName) =>
@ -59,7 +59,11 @@ export class MCPServersInitializer {
/** Initializes a single server with all its metadata and adds it to appropriate collections */
public static async initializeServer(serverName: string, rawConfig: t.MCPOptions): Promise<void> {
try {
const config = await registry.addServer(serverName, rawConfig, 'CACHE');
const config = await MCPServersRegistry.getInstance().addServer(
serverName,
rawConfig,
'CACHE',
);
MCPServersInitializer.logParsedConfig(serverName, config);
} catch (error) {
logger.error(`${MCPServersInitializer.prefix(serverName)} Failed to initialize:`, error);

View file

@ -1,8 +1,9 @@
import { logger } from '@librechat/data-schemas';
import type { IServerConfigsRepositoryInterface } from './ServerConfigsRepositoryInterface';
import type * as t from '~/mcp/types';
import { ServerConfigsCacheFactory } from './cache/ServerConfigsCacheFactory';
import { MCPServerInspector } from './MCPServerInspector';
import { ServerConfigsDB } from './db/ServerConfigsDB';
import { IServerConfigsRepositoryInterface } from './ServerConfigsRepositoryInterface';
/**
* Central registry for managing MCP server configurations.
@ -14,15 +15,42 @@ import { IServerConfigsRepositoryInterface } from './ServerConfigsRepositoryInte
*
* Query priority: Cache configs are checked first, then DB configs.
*/
class MCPServersRegistry {
export class MCPServersRegistry {
private static instance: MCPServersRegistry;
private readonly dbConfigsRepo: IServerConfigsRepositoryInterface;
private readonly cacheConfigsRepo: IServerConfigsRepositoryInterface;
constructor() {
this.dbConfigsRepo = new ServerConfigsDB();
constructor(mongoose: typeof import('mongoose')) {
this.dbConfigsRepo = new ServerConfigsDB(mongoose);
this.cacheConfigsRepo = ServerConfigsCacheFactory.create('App', false);
}
/** Creates and initializes the singleton MCPServersRegistry instance */
public static createInstance(mongoose: typeof import('mongoose')): MCPServersRegistry {
if (!mongoose) {
throw new Error(
'MCPServersRegistry creation failed: mongoose instance is required for database operations. ' +
'Ensure mongoose is initialized before creating the registry.',
);
}
if (MCPServersRegistry.instance) {
logger.debug('[MCPServersRegistry] Returning existing instance');
return MCPServersRegistry.instance;
}
logger.info('[MCPServersRegistry] Creating new instance');
MCPServersRegistry.instance = new MCPServersRegistry(mongoose);
return MCPServersRegistry.instance;
}
/** Returns the singleton MCPServersRegistry instance */
public static getInstance(): MCPServersRegistry {
if (!MCPServersRegistry.instance) {
throw new Error('MCPServersRegistry has not been initialized.');
}
return MCPServersRegistry.instance;
}
public async getServerConfig(
serverName: string,
userId?: string,
@ -100,5 +128,3 @@ class MCPServersRegistry {
}
}
}
export const mcpServersRegistry = new MCPServersRegistry();

View file

@ -1,5 +1,6 @@
import type * as t from '~/mcp/types';
import type { MCPConnection } from '~/mcp/connection';
import type { MCPServersRegistry as MCPServersRegistryType } from '../MCPServersRegistry';
// Mock isLeader to always return true to avoid lock contention during parallel operations
jest.mock('~/cluster', () => ({
@ -9,7 +10,8 @@ jest.mock('~/cluster', () => ({
describe('MCPServersInitializer Redis Integration Tests', () => {
let MCPServersInitializer: typeof import('../MCPServersInitializer').MCPServersInitializer;
let registry: typeof import('../MCPServersRegistry').mcpServersRegistry;
let MCPServersRegistry: typeof import('../MCPServersRegistry').MCPServersRegistry;
let registry: MCPServersRegistryType;
let registryStatusCache: typeof import('../cache/RegistryStatusCache').registryStatusCache;
let MCPServerInspector: typeof import('../MCPServerInspector').MCPServerInspector;
let MCPConnectionFactory: typeof import('~/mcp/MCPConnectionFactory').MCPConnectionFactory;
@ -124,15 +126,21 @@ describe('MCPServersInitializer Redis Integration Tests', () => {
const connectionFactoryModule = await import('~/mcp/MCPConnectionFactory');
const redisClients = await import('~/cache/redisClients');
const leaderElectionModule = await import('~/cluster/LeaderElection');
const mongoose = await import('mongoose');
MCPServersInitializer = initializerModule.MCPServersInitializer;
registry = registryModule.mcpServersRegistry;
MCPServersRegistry = registryModule.MCPServersRegistry;
registryStatusCache = statusCacheModule.registryStatusCache;
MCPServerInspector = inspectorModule.MCPServerInspector;
MCPConnectionFactory = connectionFactoryModule.MCPConnectionFactory;
keyvRedisClient = redisClients.keyvRedisClient;
LeaderElection = leaderElectionModule.LeaderElection;
// Reset singleton and create new instance with mongoose
(MCPServersRegistry as unknown as { instance: undefined }).instance = undefined;
MCPServersRegistry.createInstance(mongoose.default);
registry = MCPServersRegistry.getInstance();
// Ensure Redis is connected
if (!keyvRedisClient) throw new Error('Redis client is not initialized');

View file

@ -5,11 +5,15 @@ import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer';
import { MCPConnection } from '~/mcp/connection';
import { registryStatusCache } from '~/mcp/registry/cache/RegistryStatusCache';
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
import { mcpServersRegistry as registry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
const FIXED_TIME = 1699564800000;
const originalDateNow = Date.now;
Date.now = jest.fn(() => FIXED_TIME);
// Mock mongoose for registry initialization
const mockMongoose = {} as typeof import('mongoose');
// Mock external dependencies
jest.mock('../../MCPConnectionFactory');
jest.mock('../../connection');
@ -26,6 +30,18 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
// Mock ServerConfigsDB to avoid mongoose dependency
jest.mock('~/mcp/registry/db/ServerConfigsDB', () => ({
ServerConfigsDB: jest.fn().mockImplementation(() => ({
get: jest.fn().mockResolvedValue(undefined),
getAll: jest.fn().mockResolvedValue({}),
add: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
reset: jest.fn().mockResolvedValue(undefined),
})),
}));
const mockLogger = logger as jest.Mocked<typeof logger>;
const mockInspect = MCPServerInspector.inspect as jest.MockedFunction<
typeof MCPServerInspector.inspect
@ -33,6 +49,7 @@ const mockInspect = MCPServerInspector.inspect as jest.MockedFunction<
describe('MCPServersInitializer', () => {
let mockConnection: jest.Mocked<MCPConnection>;
let registry: MCPServersRegistry;
afterAll(() => {
Date.now = originalDateNow;
@ -134,6 +151,13 @@ describe('MCPServersInitializer', () => {
};
beforeEach(async () => {
// Reset the singleton instance before each test
(MCPServersRegistry as unknown as { instance: undefined }).instance = undefined;
// Create a new instance for testing
MCPServersRegistry.createInstance(mockMongoose);
registry = MCPServersRegistry.getInstance();
// Setup MCPConnection mock
mockConnection = {
disconnect: jest.fn().mockResolvedValue(undefined),

View file

@ -1,5 +1,7 @@
import { expect } from '@playwright/test';
import { MongoMemoryServer } from 'mongodb-memory-server';
import type * as t from '~/mcp/types';
import type { MCPServersRegistry as MCPServersRegistryType } from '../MCPServersRegistry';
/**
* Integration tests for MCPServersRegistry using Redis-backed cache.
@ -10,11 +12,13 @@ import type * as t from '~/mcp/types';
* The registry now uses a unified cache repository for YAML configs only.
*/
describe('MCPServersRegistry Redis Integration Tests', () => {
let registry: typeof import('../MCPServersRegistry').mcpServersRegistry;
let MCPServersRegistry: typeof import('../MCPServersRegistry').MCPServersRegistry;
let registry: MCPServersRegistryType;
let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient'];
let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection;
let leaderInstance: InstanceType<typeof import('~/cluster/LeaderElection').LeaderElection>;
let MCPServerInspector: typeof import('../MCPServerInspector').MCPServerInspector;
let mongoServer: MongoMemoryServer;
const testParsedConfig: t.ParsedServerConfig = {
type: 'stdio',
@ -55,12 +59,27 @@ describe('MCPServersRegistry Redis Integration Tests', () => {
const redisClients = await import('~/cache/redisClients');
const leaderElectionModule = await import('~/cluster/LeaderElection');
const inspectorModule = await import('../MCPServerInspector');
const mongoose = await import('mongoose');
const { userSchema } = await import('@librechat/data-schemas');
registry = registryModule.mcpServersRegistry;
MCPServersRegistry = registryModule.MCPServersRegistry;
keyvRedisClient = redisClients.keyvRedisClient;
LeaderElection = leaderElectionModule.LeaderElection;
MCPServerInspector = inspectorModule.MCPServerInspector;
// Set up MongoDB with MongoMemoryServer for db methods
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
if (!mongoose.default.models.User) {
mongoose.default.model('User', userSchema);
}
await mongoose.default.connect(mongoUri);
// Reset singleton and create new instance with mongoose
(MCPServersRegistry as unknown as { instance: undefined }).instance = undefined;
MCPServersRegistry.createInstance(mongoose.default);
registry = MCPServersRegistry.getInstance();
// Ensure Redis is connected
if (!keyvRedisClient) throw new Error('Redis client is not initialized');
@ -76,13 +95,15 @@ describe('MCPServersRegistry Redis Integration Tests', () => {
beforeEach(() => {
// Mock MCPServerInspector.inspect to avoid actual server connections
// Use mockImplementation to return the config that's actually passed in
jest.spyOn(MCPServerInspector, 'inspect').mockImplementation(async (_serverName: string, rawConfig: t.MCPOptions) => {
return {
...testParsedConfig,
...rawConfig,
requiresOAuth: false,
} as unknown as t.ParsedServerConfig;
});
jest
.spyOn(MCPServerInspector, 'inspect')
.mockImplementation(async (_serverName: string, rawConfig: t.MCPOptions) => {
return {
...testParsedConfig,
...rawConfig,
requiresOAuth: false,
} as unknown as t.ParsedServerConfig;
});
});
afterEach(async () => {
@ -114,6 +135,11 @@ describe('MCPServersRegistry Redis Integration Tests', () => {
// Close Redis connection
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
// Close MongoDB connection and stop memory server
const mongoose = await import('mongoose');
await mongoose.default.disconnect();
if (mongoServer) await mongoServer.stop();
});
// Tests for the old privateServersCache API have been removed

View file

@ -1,18 +1,36 @@
import * as t from '~/mcp/types';
import { mcpServersRegistry as registry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
// Mock MCPServerInspector to avoid actual server connections
jest.mock('~/mcp/registry/MCPServerInspector');
// Mock ServerConfigsDB to avoid mongoose dependency
jest.mock('~/mcp/registry/db/ServerConfigsDB', () => ({
ServerConfigsDB: jest.fn().mockImplementation(() => ({
get: jest.fn().mockResolvedValue(undefined),
getAll: jest.fn().mockResolvedValue({}),
add: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
reset: jest.fn().mockResolvedValue(undefined),
})),
}));
const FIXED_TIME = 1699564800000;
const originalDateNow = Date.now;
Date.now = jest.fn(() => FIXED_TIME);
// Mock mongoose for registry initialization
const mockMongoose = {} as typeof import('mongoose');
/**
* Unit tests for MCPServersRegistry using in-memory cache.
* For integration tests using Redis-backed cache, see MCPServersRegistry.cache_integration.spec.ts
*/
describe('MCPServersRegistry', () => {
let registry: MCPServersRegistry;
const testParsedConfig: t.ParsedServerConfig = {
type: 'stdio',
command: 'node',
@ -41,6 +59,13 @@ describe('MCPServersRegistry', () => {
Date.now = originalDateNow;
});
beforeEach(async () => {
// Reset the singleton instance before each test
(MCPServersRegistry as unknown as { instance: undefined }).instance = undefined;
// Create a new instance for testing
MCPServersRegistry.createInstance(mockMongoose);
registry = MCPServersRegistry.getInstance();
// Mock MCPServerInspector.inspect to return the config that's passed in
jest
.spyOn(MCPServerInspector, 'inspect')

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { ParsedServerConfig } from '~/mcp/types';
import { IServerConfigsRepositoryInterface } from '../ServerConfigsRepositoryInterface';
import { logger } from '@librechat/data-schemas';
import { AllMethods, createMethods, logger } from '@librechat/data-schemas';
import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface';
import type { ParsedServerConfig } from '~/mcp/types';
/**
* DB backed config storage
@ -9,6 +9,14 @@ import { logger } from '@librechat/data-schemas';
* Will handle Permission ACL
*/
export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
private _dbMethods: AllMethods;
constructor(mongoose: typeof import('mongoose')) {
if (!mongoose) {
throw new Error('ServerConfigsDB requires mongoose instance');
}
this._dbMethods = createMethods(mongoose);
}
public async add(serverName: string, config: ParsedServerConfig, userId?: string): Promise<void> {
logger.debug('ServerConfigsDB add not yet implemented');
return;
@ -39,7 +47,8 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
* @returns record of parsed configs
*/
public async getAll(userId?: string): Promise<Record<string, ParsedServerConfig>> {
logger.debug('ServerConfigsDB getAll not yet implemented');
// TODO: Implement DB-backed config retrieval
logger.debug('[ServerConfigsDB] getAll not yet implemented', { userId });
return {};
}