mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-13 11:26:18 +01:00
🧬 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:
parent
da473bf43a
commit
ad6ba4b6d1
24 changed files with 328 additions and 150 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue