mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-25 19:56:13 +01:00
🔄 refactor: MCP Registry System with Distributed Caching (#10191)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* refactor: Restructure MCP registry system with caching - Split MCPServersRegistry into modular components: - MCPServerInspector: handles server inspection and health checks - MCPServersInitializer: manages server initialization logic - MCPServersRegistry: simplified registry coordination - Add distributed caching layer: - ServerConfigsCacheRedis: Redis-backed configuration cache - ServerConfigsCacheInMemory: in-memory fallback cache - RegistryStatusCache: distributed leader election state - Add promise utilities (withTimeout) replacing Promise.race patterns - Add comprehensive cache integration tests for all cache implementations - Remove unused MCPManager.getAllToolFunctions method * fix: Update OAuth flow to include user-specific headers * chore: Update Jest configuration to ignore additional test files - Added patterns to ignore files ending with .helper.ts and .helper.d.ts in testPathIgnorePatterns for cleaner test runs. * fix: oauth headers in callback * chore: Update Jest testPathIgnorePatterns to exclude helper files - Modified testPathIgnorePatterns in package.json to ignore files ending with .helper.ts and .helper.d.ts for cleaner test execution. * ci: update test mocks --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
961f87cfda
commit
ce7e6edad8
45 changed files with 3116 additions and 1150 deletions
123
packages/api/src/mcp/registry/MCPServerInspector.ts
Normal file
123
packages/api/src/mcp/registry/MCPServerInspector.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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 { detectOAuthRequirement } from '~/mcp/oauth';
|
||||
import { isEnabled } from '~/utils';
|
||||
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
||||
|
||||
/**
|
||||
* Inspects MCP servers to discover their metadata, capabilities, and tools.
|
||||
* Connects to servers and populates configuration with OAuth requirements,
|
||||
* server instructions, capabilities, and available tools.
|
||||
*/
|
||||
export class MCPServerInspector {
|
||||
private constructor(
|
||||
private readonly serverName: string,
|
||||
private readonly config: t.ParsedServerConfig,
|
||||
private connection: MCPConnection | undefined,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Inspects a server and returns an enriched configuration with metadata.
|
||||
* Detects OAuth requirements and fetches server capabilities.
|
||||
* @param serverName - The name of the server (used for tool function naming)
|
||||
* @param rawConfig - The raw server configuration
|
||||
* @param connection - The MCP connection
|
||||
* @returns A fully processed and enriched configuration with server metadata
|
||||
*/
|
||||
public static async inspect(
|
||||
serverName: string,
|
||||
rawConfig: t.MCPOptions,
|
||||
connection?: MCPConnection,
|
||||
): Promise<t.ParsedServerConfig> {
|
||||
const start = Date.now();
|
||||
const inspector = new MCPServerInspector(serverName, rawConfig, connection);
|
||||
await inspector.inspectServer();
|
||||
inspector.config.initDuration = Date.now() - start;
|
||||
return inspector.config;
|
||||
}
|
||||
|
||||
private async inspectServer(): Promise<void> {
|
||||
await this.detectOAuth();
|
||||
|
||||
if (this.config.startup !== false && !this.config.requiresOAuth) {
|
||||
let tempConnection = false;
|
||||
if (!this.connection) {
|
||||
tempConnection = true;
|
||||
this.connection = await MCPConnectionFactory.create({
|
||||
serverName: this.serverName,
|
||||
serverConfig: this.config,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
this.fetchServerInstructions(),
|
||||
this.fetchServerCapabilities(),
|
||||
this.fetchToolFunctions(),
|
||||
]);
|
||||
|
||||
if (tempConnection) await this.connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async detectOAuth(): Promise<void> {
|
||||
if (this.config.requiresOAuth != null) return;
|
||||
if (this.config.url == null || this.config.startup === false) {
|
||||
this.config.requiresOAuth = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await detectOAuthRequirement(this.config.url);
|
||||
this.config.requiresOAuth = result.requiresOAuth;
|
||||
this.config.oauthMetadata = result.metadata;
|
||||
}
|
||||
|
||||
private async fetchServerInstructions(): Promise<void> {
|
||||
if (isEnabled(this.config.serverInstructions)) {
|
||||
this.config.serverInstructions = this.connection!.client.getInstructions();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchServerCapabilities(): Promise<void> {
|
||||
const capabilities = this.connection!.client.getServerCapabilities();
|
||||
this.config.capabilities = JSON.stringify(capabilities);
|
||||
const tools = await this.connection!.client.listTools();
|
||||
this.config.tools = tools.tools.map((tool) => tool.name).join(', ');
|
||||
}
|
||||
|
||||
private async fetchToolFunctions(): Promise<void> {
|
||||
this.config.toolFunctions = await MCPServerInspector.getToolFunctions(
|
||||
this.serverName,
|
||||
this.connection!,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts server tools to LibreChat-compatible tool functions format.
|
||||
* @param serverName - The name of the server
|
||||
* @param connection - The MCP connection
|
||||
* @returns Tool functions formatted for LibreChat
|
||||
*/
|
||||
public static async getToolFunctions(
|
||||
serverName: string,
|
||||
connection: MCPConnection,
|
||||
): Promise<t.LCAvailableTools> {
|
||||
const { tools }: t.MCPToolListResponse = await connection.client.listTools();
|
||||
|
||||
const toolFunctions: t.LCAvailableTools = {};
|
||||
tools.forEach((tool) => {
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
toolFunctions[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema as JsonSchemaType,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return toolFunctions;
|
||||
}
|
||||
}
|
||||
96
packages/api/src/mcp/registry/MCPServersInitializer.ts
Normal file
96
packages/api/src/mcp/registry/MCPServersInitializer.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { registryStatusCache as statusCache } from './cache/RegistryStatusCache';
|
||||
import { isLeader } from '~/cluster';
|
||||
import { withTimeout } from '~/utils';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { MCPServerInspector } from './MCPServerInspector';
|
||||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
import { sanitizeUrlForLogging } from '~/mcp/utils';
|
||||
import type * as t from '~/mcp/types';
|
||||
import { mcpServersRegistry as registry } from './MCPServersRegistry';
|
||||
|
||||
const MCP_INIT_TIMEOUT_MS =
|
||||
process.env.MCP_INIT_TIMEOUT_MS != null ? parseInt(process.env.MCP_INIT_TIMEOUT_MS) : 30_000;
|
||||
|
||||
/**
|
||||
* Handles initialization of MCP servers at application startup with distributed coordination.
|
||||
* In cluster environments, ensures only the leader node performs initialization while followers wait.
|
||||
* Connects to each configured MCP server, inspects capabilities and tools, then caches the results.
|
||||
* Categorizes servers as either shared app servers (auto-started) or shared user servers (OAuth/on-demand).
|
||||
* Uses a timeout mechanism to prevent hanging on unresponsive servers during initialization.
|
||||
*/
|
||||
export class MCPServersInitializer {
|
||||
/**
|
||||
* Initializes MCP servers with distributed leader-follower coordination.
|
||||
*
|
||||
* Design rationale:
|
||||
* - Handles leader crash scenarios: If the leader crashes during initialization, all followers
|
||||
* will independently attempt initialization after a 3-second delay. The first to become leader
|
||||
* will complete the initialization.
|
||||
* - Only the leader performs the actual initialization work (reset caches, inspect servers).
|
||||
* When complete, the leader signals completion via `statusCache`, allowing followers to proceed.
|
||||
* - Followers wait and poll `statusCache` until the leader finishes, ensuring only one node
|
||||
* performs the expensive initialization operations.
|
||||
*/
|
||||
public static async initialize(rawConfigs: t.MCPServers): Promise<void> {
|
||||
if (await statusCache.isInitialized()) return;
|
||||
|
||||
if (await isLeader()) {
|
||||
// Leader performs initialization
|
||||
await statusCache.reset();
|
||||
await registry.reset();
|
||||
const serverNames = Object.keys(rawConfigs);
|
||||
await Promise.allSettled(
|
||||
serverNames.map((serverName) =>
|
||||
withTimeout(
|
||||
MCPServersInitializer.initializeServer(serverName, rawConfigs[serverName]),
|
||||
MCP_INIT_TIMEOUT_MS,
|
||||
`${MCPServersInitializer.prefix(serverName)} Server initialization timed out`,
|
||||
logger.error,
|
||||
),
|
||||
),
|
||||
);
|
||||
await statusCache.setInitialized(true);
|
||||
} else {
|
||||
// Followers try again after a delay if not initialized
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await this.initialize(rawConfigs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Initializes a single server with all its metadata and adds it to appropriate collections */
|
||||
private static async initializeServer(
|
||||
serverName: string,
|
||||
rawConfig: t.MCPOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await MCPServerInspector.inspect(serverName, rawConfig);
|
||||
|
||||
if (config.startup === false || config.requiresOAuth) {
|
||||
await registry.sharedUserServers.add(serverName, config);
|
||||
} else {
|
||||
await registry.sharedAppServers.add(serverName, config);
|
||||
}
|
||||
MCPServersInitializer.logParsedConfig(serverName, config);
|
||||
} catch (error) {
|
||||
logger.error(`${MCPServersInitializer.prefix(serverName)} Failed to initialize:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Logs server configuration summary after initialization
|
||||
private static logParsedConfig(serverName: string, config: ParsedServerConfig): void {
|
||||
const prefix = MCPServersInitializer.prefix(serverName);
|
||||
logger.info(`${prefix} -------------------------------------------------┐`);
|
||||
logger.info(`${prefix} URL: ${config.url ? sanitizeUrlForLogging(config.url) : 'N/A'}`);
|
||||
logger.info(`${prefix} OAuth Required: ${config.requiresOAuth}`);
|
||||
logger.info(`${prefix} Capabilities: ${config.capabilities}`);
|
||||
logger.info(`${prefix} Tools: ${config.tools}`);
|
||||
logger.info(`${prefix} Server Instructions: ${config.serverInstructions}`);
|
||||
logger.info(`${prefix} Initialized in: ${config.initDuration ?? 'N/A'}ms`);
|
||||
logger.info(`${prefix} -------------------------------------------------┘`);
|
||||
}
|
||||
|
||||
// Returns formatted log prefix for server messages
|
||||
private static prefix(serverName: string): string {
|
||||
return `[MCP][${serverName}]`;
|
||||
}
|
||||
}
|
||||
91
packages/api/src/mcp/registry/MCPServersRegistry.ts
Normal file
91
packages/api/src/mcp/registry/MCPServersRegistry.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type * as t from '~/mcp/types';
|
||||
import {
|
||||
ServerConfigsCacheFactory,
|
||||
type ServerConfigsCache,
|
||||
} from './cache/ServerConfigsCacheFactory';
|
||||
|
||||
/**
|
||||
* Central registry for managing MCP server configurations across different scopes and users.
|
||||
* Maintains three categories of server configurations:
|
||||
* - Shared App Servers: Auto-started servers available to all users (initialized at startup)
|
||||
* - Shared User Servers: User-scope servers that require OAuth or on-demand startup
|
||||
* - Private User Servers: Per-user configurations dynamically added during runtime
|
||||
*
|
||||
* Provides a unified interface for retrieving server configs with proper fallback hierarchy:
|
||||
* checks shared app servers first, then shared user servers, then private user servers.
|
||||
* Handles server lifecycle operations including adding, removing, and querying configurations.
|
||||
*/
|
||||
class MCPServersRegistry {
|
||||
public readonly sharedAppServers = ServerConfigsCacheFactory.create('App', true);
|
||||
public readonly sharedUserServers = ServerConfigsCacheFactory.create('User', true);
|
||||
private readonly privateUserServers: Map<string | undefined, ServerConfigsCache> = new Map();
|
||||
|
||||
public async addPrivateUserServer(
|
||||
userId: string,
|
||||
serverName: string,
|
||||
config: t.ParsedServerConfig,
|
||||
): Promise<void> {
|
||||
if (!this.privateUserServers.has(userId)) {
|
||||
const cache = ServerConfigsCacheFactory.create(`User(${userId})`, false);
|
||||
this.privateUserServers.set(userId, cache);
|
||||
}
|
||||
await this.privateUserServers.get(userId)!.add(serverName, config);
|
||||
}
|
||||
|
||||
public async updatePrivateUserServer(
|
||||
userId: string,
|
||||
serverName: string,
|
||||
config: t.ParsedServerConfig,
|
||||
): Promise<void> {
|
||||
const userCache = this.privateUserServers.get(userId);
|
||||
if (!userCache) throw new Error(`No private servers found for user "${userId}".`);
|
||||
await userCache.update(serverName, config);
|
||||
}
|
||||
|
||||
public async removePrivateUserServer(userId: string, serverName: string): Promise<void> {
|
||||
await this.privateUserServers.get(userId)?.remove(serverName);
|
||||
}
|
||||
|
||||
public async getServerConfig(
|
||||
serverName: string,
|
||||
userId?: string,
|
||||
): Promise<t.ParsedServerConfig | undefined> {
|
||||
const sharedAppServer = await this.sharedAppServers.get(serverName);
|
||||
if (sharedAppServer) return sharedAppServer;
|
||||
|
||||
const sharedUserServer = await this.sharedUserServers.get(serverName);
|
||||
if (sharedUserServer) return sharedUserServer;
|
||||
|
||||
const privateUserServer = await this.privateUserServers.get(userId)?.get(serverName);
|
||||
if (privateUserServer) return privateUserServer;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async getAllServerConfigs(userId?: string): Promise<Record<string, t.ParsedServerConfig>> {
|
||||
return {
|
||||
...(await this.sharedAppServers.getAll()),
|
||||
...(await this.sharedUserServers.getAll()),
|
||||
...((await this.privateUserServers.get(userId)?.getAll()) ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: This is currently used to determine if a server requires OAuth. However, this info can
|
||||
// can be determined through config.requiresOAuth. Refactor usages and remove this method.
|
||||
public async getOAuthServers(userId?: string): Promise<Set<string>> {
|
||||
const allServers = await this.getAllServerConfigs(userId);
|
||||
const oauthServers = Object.entries(allServers).filter(([, config]) => config.requiresOAuth);
|
||||
return new Set(oauthServers.map(([name]) => name));
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
await this.sharedAppServers.reset();
|
||||
await this.sharedUserServers.reset();
|
||||
for (const cache of this.privateUserServers.values()) {
|
||||
await cache.reset();
|
||||
}
|
||||
this.privateUserServers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpServersRegistry = new MCPServersRegistry();
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
import type { MCPConnection } from '~/mcp/connection';
|
||||
import type * as t from '~/mcp/types';
|
||||
import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector';
|
||||
import { detectOAuthRequirement } from '~/mcp/oauth';
|
||||
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
||||
import { createMockConnection } from './mcpConnectionsMock.helper';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('../../oauth/detectOAuth');
|
||||
jest.mock('../../MCPConnectionFactory');
|
||||
|
||||
const mockDetectOAuthRequirement = detectOAuthRequirement as jest.MockedFunction<
|
||||
typeof detectOAuthRequirement
|
||||
>;
|
||||
|
||||
describe('MCPServerInspector', () => {
|
||||
let mockConnection: jest.Mocked<MCPConnection>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConnection = createMockConnection('test_server');
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('inspect()', () => {
|
||||
it('should process env and fetch all metadata for non-OAuth stdio server with serverInstructions=true', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: true,
|
||||
};
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: false,
|
||||
method: 'no-metadata-found',
|
||||
});
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: 'instructions for test_server',
|
||||
requiresOAuth: false,
|
||||
capabilities:
|
||||
'{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"get":"getPrompts for test_server"}}',
|
||||
tools: 'listFiles',
|
||||
toolFunctions: {
|
||||
listFiles_mcp_test_server: expect.objectContaining({
|
||||
type: 'function',
|
||||
function: expect.objectContaining({
|
||||
name: 'listFiles_mcp_test_server',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect OAuth and skip capabilities fetch for streamable-http server', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
};
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: true,
|
||||
method: 'protected-resource-metadata',
|
||||
});
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
requiresOAuth: true,
|
||||
oauthMetadata: undefined,
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip capabilities fetch when startup=false', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
startup: false,
|
||||
};
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
startup: false,
|
||||
requiresOAuth: false,
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep custom serverInstructions string and not fetch from server', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: 'Custom instructions here',
|
||||
};
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: false,
|
||||
method: 'no-metadata-found',
|
||||
});
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: 'Custom instructions here',
|
||||
requiresOAuth: false,
|
||||
capabilities:
|
||||
'{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"get":"getPrompts for test_server"}}',
|
||||
tools: 'listFiles',
|
||||
toolFunctions: expect.any(Object),
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle serverInstructions as string "true" and fetch from server', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: 'true', // String "true" from YAML
|
||||
};
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: false,
|
||||
method: 'no-metadata-found',
|
||||
});
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: 'instructions for test_server',
|
||||
requiresOAuth: false,
|
||||
capabilities:
|
||||
'{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"get":"getPrompts for test_server"}}',
|
||||
tools: 'listFiles',
|
||||
toolFunctions: expect.any(Object),
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle predefined requiresOAuth without detection', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'sse',
|
||||
url: 'https://api.example.com/sse',
|
||||
requiresOAuth: true,
|
||||
};
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'sse',
|
||||
url: 'https://api.example.com/sse',
|
||||
requiresOAuth: true,
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch capabilities when server has no tools', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
};
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: false,
|
||||
method: 'no-metadata-found',
|
||||
});
|
||||
|
||||
// Mock server with no tools
|
||||
mockConnection.client.listTools = jest.fn().mockResolvedValue({ tools: [] });
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
requiresOAuth: false,
|
||||
capabilities:
|
||||
'{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"get":"getPrompts for test_server"}}',
|
||||
tools: '',
|
||||
toolFunctions: {},
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create temporary connection when no connection is provided', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: true,
|
||||
};
|
||||
|
||||
const tempMockConnection = createMockConnection('test_server');
|
||||
(MCPConnectionFactory.create as jest.Mock).mockResolvedValue(tempMockConnection);
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: false,
|
||||
method: 'no-metadata-found',
|
||||
});
|
||||
|
||||
const result = await MCPServerInspector.inspect('test_server', rawConfig);
|
||||
|
||||
// Verify factory was called to create connection
|
||||
expect(MCPConnectionFactory.create).toHaveBeenCalledWith({
|
||||
serverName: 'test_server',
|
||||
serverConfig: expect.objectContaining({ type: 'stdio', command: 'node' }),
|
||||
});
|
||||
|
||||
// Verify temporary connection was disconnected
|
||||
expect(tempMockConnection.disconnect).toHaveBeenCalled();
|
||||
|
||||
// Verify result is correct
|
||||
expect(result).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: 'instructions for test_server',
|
||||
requiresOAuth: false,
|
||||
capabilities:
|
||||
'{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"get":"getPrompts for test_server"}}',
|
||||
tools: 'listFiles',
|
||||
toolFunctions: expect.any(Object),
|
||||
initDuration: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create temporary connection when connection is provided', async () => {
|
||||
const rawConfig: t.MCPOptions = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
serverInstructions: true,
|
||||
};
|
||||
|
||||
mockDetectOAuthRequirement.mockResolvedValue({
|
||||
requiresOAuth: false,
|
||||
method: 'no-metadata-found',
|
||||
});
|
||||
|
||||
await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||
|
||||
// Verify factory was NOT called
|
||||
expect(MCPConnectionFactory.create).not.toHaveBeenCalled();
|
||||
|
||||
// Verify provided connection was NOT disconnected
|
||||
expect(mockConnection.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolFunctions()', () => {
|
||||
it('should convert MCP tools to LibreChat tool functions format', async () => {
|
||||
mockConnection.client.listTools = jest.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'file_read',
|
||||
description: 'Read a file',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'file_write',
|
||||
description: 'Write a file',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await MCPServerInspector.getToolFunctions('my_server', mockConnection);
|
||||
|
||||
expect(result).toEqual({
|
||||
file_read_mcp_my_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'file_read_mcp_my_server',
|
||||
description: 'Read a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
file_write_mcp_my_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'file_write_mcp_my_server',
|
||||
description: 'Write a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty tools list', async () => {
|
||||
mockConnection.client.listTools = jest.fn().mockResolvedValue({ tools: [] });
|
||||
|
||||
const result = await MCPServerInspector.getToolFunctions('my_server', mockConnection);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import type * as t from '~/mcp/types';
|
||||
import type { MCPConnection } from '~/mcp/connection';
|
||||
|
||||
// Mock isLeader to always return true to avoid lock contention during parallel operations
|
||||
jest.mock('~/cluster', () => ({
|
||||
...jest.requireActual('~/cluster'),
|
||||
isLeader: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('MCPServersInitializer Redis Integration Tests', () => {
|
||||
let MCPServersInitializer: typeof import('../MCPServersInitializer').MCPServersInitializer;
|
||||
let registry: typeof import('../MCPServersRegistry').mcpServersRegistry;
|
||||
let registryStatusCache: typeof import('../cache/RegistryStatusCache').registryStatusCache;
|
||||
let MCPServerInspector: typeof import('../MCPServerInspector').MCPServerInspector;
|
||||
let MCPConnectionFactory: typeof import('~/mcp/MCPConnectionFactory').MCPConnectionFactory;
|
||||
let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient'];
|
||||
let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection;
|
||||
let leaderInstance: InstanceType<typeof import('~/cluster/LeaderElection').LeaderElection>;
|
||||
|
||||
const testConfigs: t.MCPServers = {
|
||||
disabled_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['disabled.js'],
|
||||
startup: false,
|
||||
},
|
||||
oauth_server: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
},
|
||||
file_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['tools.js'],
|
||||
},
|
||||
search_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['instructions.js'],
|
||||
},
|
||||
};
|
||||
|
||||
const testParsedConfigs: Record<string, t.ParsedServerConfig> = {
|
||||
disabled_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['disabled.js'],
|
||||
startup: false,
|
||||
requiresOAuth: false,
|
||||
},
|
||||
oauth_server: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
requiresOAuth: true,
|
||||
},
|
||||
file_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['tools.js'],
|
||||
requiresOAuth: false,
|
||||
serverInstructions: 'Instructions for file_tools_server',
|
||||
tools: 'file_read, file_write',
|
||||
capabilities: '{"tools":{"listChanged":true}}',
|
||||
toolFunctions: {
|
||||
file_read_mcp_file_tools_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'file_read_mcp_file_tools_server',
|
||||
description: 'Read a file',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
search_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['instructions.js'],
|
||||
requiresOAuth: false,
|
||||
serverInstructions: 'Instructions for search_tools_server',
|
||||
capabilities: '{"tools":{"listChanged":true}}',
|
||||
tools: 'search',
|
||||
toolFunctions: {
|
||||
search_mcp_search_tools_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_mcp_search_tools_server',
|
||||
description: 'Search tool',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment variables for Redis (only if not already set)
|
||||
process.env.USE_REDIS = process.env.USE_REDIS ?? 'true';
|
||||
process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379';
|
||||
process.env.REDIS_KEY_PREFIX =
|
||||
process.env.REDIS_KEY_PREFIX ?? 'MCPServersInitializer-IntegrationTest';
|
||||
|
||||
// Import modules after setting env vars
|
||||
const initializerModule = await import('../MCPServersInitializer');
|
||||
const registryModule = await import('../MCPServersRegistry');
|
||||
const statusCacheModule = await import('../cache/RegistryStatusCache');
|
||||
const inspectorModule = await import('../MCPServerInspector');
|
||||
const connectionFactoryModule = await import('~/mcp/MCPConnectionFactory');
|
||||
const redisClients = await import('~/cache/redisClients');
|
||||
const leaderElectionModule = await import('~/cluster/LeaderElection');
|
||||
|
||||
MCPServersInitializer = initializerModule.MCPServersInitializer;
|
||||
registry = registryModule.mcpServersRegistry;
|
||||
registryStatusCache = statusCacheModule.registryStatusCache;
|
||||
MCPServerInspector = inspectorModule.MCPServerInspector;
|
||||
MCPConnectionFactory = connectionFactoryModule.MCPConnectionFactory;
|
||||
keyvRedisClient = redisClients.keyvRedisClient;
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
|
||||
// Ensure Redis is connected
|
||||
if (!keyvRedisClient) throw new Error('Redis client is not initialized');
|
||||
|
||||
// Wait for Redis to be ready
|
||||
if (!keyvRedisClient.isOpen) await keyvRedisClient.connect();
|
||||
|
||||
// Become leader so we can perform write operations
|
||||
leaderInstance = new LeaderElection();
|
||||
const isLeader = await leaderInstance.isLeader();
|
||||
expect(isLeader).toBe(true);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ensure we're still the leader
|
||||
const isLeader = await leaderInstance.isLeader();
|
||||
if (!isLeader) {
|
||||
throw new Error('Lost leader status before test');
|
||||
}
|
||||
|
||||
// Mock MCPServerInspector.inspect to return parsed config
|
||||
jest.spyOn(MCPServerInspector, 'inspect').mockImplementation(async (serverName: string) => {
|
||||
return {
|
||||
...testParsedConfigs[serverName],
|
||||
_processedByInspector: true,
|
||||
} as unknown as t.ParsedServerConfig;
|
||||
});
|
||||
|
||||
// Mock MCPConnection
|
||||
const mockConnection = {
|
||||
disconnect: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<MCPConnection>;
|
||||
|
||||
// Mock MCPConnectionFactory
|
||||
jest.spyOn(MCPConnectionFactory, 'create').mockResolvedValue(mockConnection);
|
||||
|
||||
// Reset caches before each test
|
||||
await registryStatusCache.reset();
|
||||
await registry.reset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: clear all test keys from Redis
|
||||
if (keyvRedisClient) {
|
||||
const pattern = '*MCPServersInitializer-IntegrationTest*';
|
||||
if ('scanIterator' in keyvRedisClient) {
|
||||
for await (const key of keyvRedisClient.scanIterator({ MATCH: pattern })) {
|
||||
await keyvRedisClient.del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Resign as leader
|
||||
if (leaderInstance) await leaderInstance.resign();
|
||||
|
||||
// Close Redis connection
|
||||
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
|
||||
});
|
||||
|
||||
describe('initialize()', () => {
|
||||
it('should reset registry and status cache before initialization', async () => {
|
||||
// Pre-populate registry with some old servers
|
||||
await registry.sharedAppServers.add('old_app_server', testParsedConfigs.file_tools_server);
|
||||
await registry.sharedUserServers.add('old_user_server', testParsedConfigs.oauth_server);
|
||||
|
||||
// Initialize with new configs (this should reset first)
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify old servers are gone
|
||||
expect(await registry.sharedAppServers.get('old_app_server')).toBeUndefined();
|
||||
expect(await registry.sharedUserServers.get('old_user_server')).toBeUndefined();
|
||||
|
||||
// Verify new servers are present
|
||||
expect(await registry.sharedAppServers.get('file_tools_server')).toBeDefined();
|
||||
expect(await registry.sharedUserServers.get('oauth_server')).toBeDefined();
|
||||
expect(await registryStatusCache.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip initialization if already initialized', async () => {
|
||||
// First initialization
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Clear mock calls
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Second initialization should skip due to static flag
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify inspect was not called again
|
||||
expect(MCPServerInspector.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add disabled servers to sharedUserServers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
const disabledServer = await registry.sharedUserServers.get('disabled_server');
|
||||
expect(disabledServer).toBeDefined();
|
||||
expect(disabledServer).toMatchObject({
|
||||
...testParsedConfigs.disabled_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add OAuth servers to sharedUserServers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
const oauthServer = await registry.sharedUserServers.get('oauth_server');
|
||||
expect(oauthServer).toBeDefined();
|
||||
expect(oauthServer).toMatchObject({
|
||||
...testParsedConfigs.oauth_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add enabled non-OAuth servers to sharedAppServers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
const fileToolsServer = await registry.sharedAppServers.get('file_tools_server');
|
||||
expect(fileToolsServer).toBeDefined();
|
||||
expect(fileToolsServer).toMatchObject({
|
||||
...testParsedConfigs.file_tools_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
|
||||
const searchToolsServer = await registry.sharedAppServers.get('search_tools_server');
|
||||
expect(searchToolsServer).toBeDefined();
|
||||
expect(searchToolsServer).toMatchObject({
|
||||
...testParsedConfigs.search_tools_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully initialize all servers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify all servers were added to appropriate registries
|
||||
expect(await registry.sharedUserServers.get('disabled_server')).toBeDefined();
|
||||
expect(await registry.sharedUserServers.get('oauth_server')).toBeDefined();
|
||||
expect(await registry.sharedAppServers.get('file_tools_server')).toBeDefined();
|
||||
expect(await registry.sharedAppServers.get('search_tools_server')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle inspection failures gracefully', async () => {
|
||||
// Mock inspection failure for one server
|
||||
jest.spyOn(MCPServerInspector, 'inspect').mockImplementation(async (serverName: string) => {
|
||||
if (serverName === 'file_tools_server') {
|
||||
throw new Error('Inspection failed');
|
||||
}
|
||||
return {
|
||||
...testParsedConfigs[serverName],
|
||||
_processedByInspector: true,
|
||||
} as unknown as t.ParsedServerConfig;
|
||||
});
|
||||
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify other servers were still processed
|
||||
const disabledServer = await registry.sharedUserServers.get('disabled_server');
|
||||
expect(disabledServer).toBeDefined();
|
||||
|
||||
const oauthServer = await registry.sharedUserServers.get('oauth_server');
|
||||
expect(oauthServer).toBeDefined();
|
||||
|
||||
const searchToolsServer = await registry.sharedAppServers.get('search_tools_server');
|
||||
expect(searchToolsServer).toBeDefined();
|
||||
|
||||
// Verify file_tools_server was not added (due to inspection failure)
|
||||
const fileToolsServer = await registry.sharedAppServers.get('file_tools_server');
|
||||
expect(fileToolsServer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set initialized status after completion', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
expect(await registryStatusCache.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import * as t from '~/mcp/types';
|
||||
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
||||
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';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('../../MCPConnectionFactory');
|
||||
jest.mock('../../connection');
|
||||
jest.mock('../../registry/MCPServerInspector');
|
||||
jest.mock('~/cluster', () => ({
|
||||
isLeader: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockLogger = logger as jest.Mocked<typeof logger>;
|
||||
const mockInspect = MCPServerInspector.inspect as jest.MockedFunction<
|
||||
typeof MCPServerInspector.inspect
|
||||
>;
|
||||
|
||||
describe('MCPServersInitializer', () => {
|
||||
let mockConnection: jest.Mocked<MCPConnection>;
|
||||
|
||||
const testConfigs: t.MCPServers = {
|
||||
disabled_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['disabled.js'],
|
||||
startup: false,
|
||||
},
|
||||
oauth_server: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
},
|
||||
file_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['tools.js'],
|
||||
},
|
||||
search_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['instructions.js'],
|
||||
},
|
||||
};
|
||||
|
||||
const testParsedConfigs: Record<string, t.ParsedServerConfig> = {
|
||||
disabled_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['disabled.js'],
|
||||
startup: false,
|
||||
requiresOAuth: false,
|
||||
},
|
||||
oauth_server: {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.example.com/mcp',
|
||||
requiresOAuth: true,
|
||||
},
|
||||
file_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['tools.js'],
|
||||
requiresOAuth: false,
|
||||
serverInstructions: 'Instructions for file_tools_server',
|
||||
tools: 'file_read, file_write',
|
||||
capabilities: '{"tools":{"listChanged":true}}',
|
||||
toolFunctions: {
|
||||
file_read_mcp_file_tools_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'file_read_mcp_file_tools_server',
|
||||
description: 'Read a file',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
search_tools_server: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['instructions.js'],
|
||||
requiresOAuth: false,
|
||||
serverInstructions: 'Instructions for search_tools_server',
|
||||
capabilities: '{"tools":{"listChanged":true}}',
|
||||
tools: 'search',
|
||||
toolFunctions: {
|
||||
search_mcp_search_tools_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_mcp_search_tools_server',
|
||||
description: 'Search tool',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// Setup MCPConnection mock
|
||||
mockConnection = {
|
||||
disconnect: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<MCPConnection>;
|
||||
|
||||
// Setup MCPConnectionFactory mock
|
||||
(MCPConnectionFactory.create as jest.Mock).mockResolvedValue(mockConnection);
|
||||
|
||||
// Mock MCPServerInspector.inspect to return parsed config
|
||||
mockInspect.mockImplementation(async (serverName: string) => {
|
||||
return {
|
||||
...testParsedConfigs[serverName],
|
||||
_processedByInspector: true,
|
||||
} as unknown as t.ParsedServerConfig;
|
||||
});
|
||||
|
||||
// Reset caches before each test
|
||||
await registryStatusCache.reset();
|
||||
await registry.sharedAppServers.reset();
|
||||
await registry.sharedUserServers.reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initialize()', () => {
|
||||
it('should reset registry and status cache before initialization', async () => {
|
||||
// Pre-populate registry with some old servers
|
||||
await registry.sharedAppServers.add('old_app_server', testParsedConfigs.file_tools_server);
|
||||
await registry.sharedUserServers.add('old_user_server', testParsedConfigs.oauth_server);
|
||||
|
||||
// Initialize with new configs (this should reset first)
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify old servers are gone
|
||||
expect(await registry.sharedAppServers.get('old_app_server')).toBeUndefined();
|
||||
expect(await registry.sharedUserServers.get('old_user_server')).toBeUndefined();
|
||||
|
||||
// Verify new servers are present
|
||||
expect(await registry.sharedAppServers.get('file_tools_server')).toBeDefined();
|
||||
expect(await registry.sharedUserServers.get('oauth_server')).toBeDefined();
|
||||
expect(await registryStatusCache.isInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip initialization if already initialized (Redis flag)', async () => {
|
||||
// First initialization
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Second initialization should skip due to Redis cache flag
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
expect(mockInspect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process all server configs through inspector', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify all configs were processed by inspector (without connection parameter)
|
||||
expect(mockInspect).toHaveBeenCalledTimes(4);
|
||||
expect(mockInspect).toHaveBeenCalledWith('disabled_server', testConfigs.disabled_server);
|
||||
expect(mockInspect).toHaveBeenCalledWith('oauth_server', testConfigs.oauth_server);
|
||||
expect(mockInspect).toHaveBeenCalledWith('file_tools_server', testConfigs.file_tools_server);
|
||||
expect(mockInspect).toHaveBeenCalledWith(
|
||||
'search_tools_server',
|
||||
testConfigs.search_tools_server,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add disabled servers to sharedUserServers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
const disabledServer = await registry.sharedUserServers.get('disabled_server');
|
||||
expect(disabledServer).toBeDefined();
|
||||
expect(disabledServer).toMatchObject({
|
||||
...testParsedConfigs.disabled_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add OAuth servers to sharedUserServers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
const oauthServer = await registry.sharedUserServers.get('oauth_server');
|
||||
expect(oauthServer).toBeDefined();
|
||||
expect(oauthServer).toMatchObject({
|
||||
...testParsedConfigs.oauth_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add enabled non-OAuth servers to sharedAppServers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
const fileToolsServer = await registry.sharedAppServers.get('file_tools_server');
|
||||
expect(fileToolsServer).toBeDefined();
|
||||
expect(fileToolsServer).toMatchObject({
|
||||
...testParsedConfigs.file_tools_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
|
||||
const searchToolsServer = await registry.sharedAppServers.get('search_tools_server');
|
||||
expect(searchToolsServer).toBeDefined();
|
||||
expect(searchToolsServer).toMatchObject({
|
||||
...testParsedConfigs.search_tools_server,
|
||||
_processedByInspector: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should successfully initialize all servers', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify all servers were added to appropriate registries
|
||||
expect(await registry.sharedUserServers.get('disabled_server')).toBeDefined();
|
||||
expect(await registry.sharedUserServers.get('oauth_server')).toBeDefined();
|
||||
expect(await registry.sharedAppServers.get('file_tools_server')).toBeDefined();
|
||||
expect(await registry.sharedAppServers.get('search_tools_server')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle inspection failures gracefully', async () => {
|
||||
// Mock inspection failure for one server
|
||||
mockInspect.mockImplementation(async (serverName: string) => {
|
||||
if (serverName === 'file_tools_server') {
|
||||
throw new Error('Inspection failed');
|
||||
}
|
||||
return {
|
||||
...testParsedConfigs[serverName],
|
||||
_processedByInspector: true,
|
||||
} as unknown as t.ParsedServerConfig;
|
||||
});
|
||||
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify other servers were still processed
|
||||
const disabledServer = await registry.sharedUserServers.get('disabled_server');
|
||||
expect(disabledServer).toBeDefined();
|
||||
|
||||
const oauthServer = await registry.sharedUserServers.get('oauth_server');
|
||||
expect(oauthServer).toBeDefined();
|
||||
|
||||
const searchToolsServer = await registry.sharedAppServers.get('search_tools_server');
|
||||
expect(searchToolsServer).toBeDefined();
|
||||
|
||||
// Verify file_tools_server was not added (due to inspection failure)
|
||||
const fileToolsServer = await registry.sharedAppServers.get('file_tools_server');
|
||||
expect(fileToolsServer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should log server configuration after initialization', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
// Verify logging occurred for each server
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[MCP][disabled_server]'),
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('[MCP][oauth_server]'));
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[MCP][file_tools_server]'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use Promise.allSettled for parallel server initialization', async () => {
|
||||
const allSettledSpy = jest.spyOn(Promise, 'allSettled');
|
||||
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
expect(allSettledSpy).toHaveBeenCalledWith(expect.arrayContaining([expect.any(Promise)]));
|
||||
expect(allSettledSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
allSettledSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should set initialized status after completion', async () => {
|
||||
await MCPServersInitializer.initialize(testConfigs);
|
||||
|
||||
expect(await registryStatusCache.isInitialized()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import type * as t from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* Integration tests for MCPServersRegistry using Redis-backed cache.
|
||||
* For unit tests using in-memory cache, see MCPServersRegistry.test.ts
|
||||
*/
|
||||
describe('MCPServersRegistry Redis Integration Tests', () => {
|
||||
let registry: typeof import('../MCPServersRegistry').mcpServersRegistry;
|
||||
let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient'];
|
||||
let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection;
|
||||
let leaderInstance: InstanceType<typeof import('~/cluster/LeaderElection').LeaderElection>;
|
||||
|
||||
const testParsedConfig: t.ParsedServerConfig = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['tools.js'],
|
||||
requiresOAuth: false,
|
||||
serverInstructions: 'Instructions for file_tools_server',
|
||||
tools: 'file_read, file_write',
|
||||
capabilities: '{"tools":{"listChanged":true}}',
|
||||
toolFunctions: {
|
||||
file_read_mcp_file_tools_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'file_read_mcp_file_tools_server',
|
||||
description: 'Read a file',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment variables for Redis (only if not already set)
|
||||
process.env.USE_REDIS = process.env.USE_REDIS ?? 'true';
|
||||
process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379';
|
||||
process.env.REDIS_KEY_PREFIX =
|
||||
process.env.REDIS_KEY_PREFIX ?? 'MCPServersRegistry-IntegrationTest';
|
||||
|
||||
// Import modules after setting env vars
|
||||
const registryModule = await import('../MCPServersRegistry');
|
||||
const redisClients = await import('~/cache/redisClients');
|
||||
const leaderElectionModule = await import('~/cluster/LeaderElection');
|
||||
|
||||
registry = registryModule.mcpServersRegistry;
|
||||
keyvRedisClient = redisClients.keyvRedisClient;
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
|
||||
// Ensure Redis is connected
|
||||
if (!keyvRedisClient) throw new Error('Redis client is not initialized');
|
||||
|
||||
// Wait for Redis to be ready
|
||||
if (!keyvRedisClient.isOpen) await keyvRedisClient.connect();
|
||||
|
||||
// Become leader so we can perform write operations
|
||||
leaderInstance = new LeaderElection();
|
||||
const isLeader = await leaderInstance.isLeader();
|
||||
expect(isLeader).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: reset registry to clear all test data
|
||||
await registry.reset();
|
||||
|
||||
// Also clean up any remaining test keys from Redis
|
||||
if (keyvRedisClient) {
|
||||
const pattern = '*MCPServersRegistry-IntegrationTest*';
|
||||
if ('scanIterator' in keyvRedisClient) {
|
||||
for await (const key of keyvRedisClient.scanIterator({ MATCH: pattern })) {
|
||||
await keyvRedisClient.del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Resign as leader
|
||||
if (leaderInstance) await leaderInstance.resign();
|
||||
|
||||
// Close Redis connection
|
||||
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
|
||||
});
|
||||
|
||||
describe('private user servers', () => {
|
||||
it('should add and remove private user server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
|
||||
// Add private user server
|
||||
await registry.addPrivateUserServer(userId, serverName, testParsedConfig);
|
||||
|
||||
// Verify server was added
|
||||
const retrievedConfig = await registry.getServerConfig(serverName, userId);
|
||||
expect(retrievedConfig).toEqual(testParsedConfig);
|
||||
|
||||
// Remove private user server
|
||||
await registry.removePrivateUserServer(userId, serverName);
|
||||
|
||||
// Verify server was removed
|
||||
const configAfterRemoval = await registry.getServerConfig(serverName, userId);
|
||||
expect(configAfterRemoval).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when adding duplicate private user server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
|
||||
await registry.addPrivateUserServer(userId, serverName, testParsedConfig);
|
||||
await expect(
|
||||
registry.addPrivateUserServer(userId, serverName, testParsedConfig),
|
||||
).rejects.toThrow(
|
||||
'Server "private_server" already exists in cache. Use update() to modify existing configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update an existing private user server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
const updatedConfig: t.ParsedServerConfig = {
|
||||
type: 'stdio',
|
||||
command: 'python',
|
||||
args: ['updated.py'],
|
||||
requiresOAuth: true,
|
||||
};
|
||||
|
||||
// Add private user server
|
||||
await registry.addPrivateUserServer(userId, serverName, testParsedConfig);
|
||||
|
||||
// Update the server config
|
||||
await registry.updatePrivateUserServer(userId, serverName, updatedConfig);
|
||||
|
||||
// Verify server was updated
|
||||
const retrievedConfig = await registry.getServerConfig(serverName, userId);
|
||||
expect(retrievedConfig).toEqual(updatedConfig);
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
|
||||
// Add a user cache first
|
||||
await registry.addPrivateUserServer(userId, 'other_server', testParsedConfig);
|
||||
|
||||
await expect(
|
||||
registry.updatePrivateUserServer(userId, serverName, testParsedConfig),
|
||||
).rejects.toThrow(
|
||||
'Server "private_server" does not exist in cache. Use add() to create new configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when updating server for non-existent user', async () => {
|
||||
const userId = 'nonexistent_user';
|
||||
const serverName = 'private_server';
|
||||
|
||||
await expect(
|
||||
registry.updatePrivateUserServer(userId, serverName, testParsedConfig),
|
||||
).rejects.toThrow('No private servers found for user "nonexistent_user".');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllServerConfigs', () => {
|
||||
it('should return correct servers based on userId', async () => {
|
||||
// Add servers to all three caches
|
||||
await registry.sharedAppServers.add('app_server', testParsedConfig);
|
||||
await registry.sharedUserServers.add('user_server', testParsedConfig);
|
||||
await registry.addPrivateUserServer('abc', 'abc_private_server', testParsedConfig);
|
||||
await registry.addPrivateUserServer('xyz', 'xyz_private_server', testParsedConfig);
|
||||
|
||||
// Without userId: should return only shared app + shared user servers
|
||||
const configsNoUser = await registry.getAllServerConfigs();
|
||||
expect(Object.keys(configsNoUser)).toHaveLength(2);
|
||||
expect(configsNoUser).toHaveProperty('app_server');
|
||||
expect(configsNoUser).toHaveProperty('user_server');
|
||||
|
||||
// With userId 'abc': should return shared app + shared user + abc's private servers
|
||||
const configsAbc = await registry.getAllServerConfigs('abc');
|
||||
expect(Object.keys(configsAbc)).toHaveLength(3);
|
||||
expect(configsAbc).toHaveProperty('app_server');
|
||||
expect(configsAbc).toHaveProperty('user_server');
|
||||
expect(configsAbc).toHaveProperty('abc_private_server');
|
||||
|
||||
// With userId 'xyz': should return shared app + shared user + xyz's private servers
|
||||
const configsXyz = await registry.getAllServerConfigs('xyz');
|
||||
expect(Object.keys(configsXyz)).toHaveLength(3);
|
||||
expect(configsXyz).toHaveProperty('app_server');
|
||||
expect(configsXyz).toHaveProperty('user_server');
|
||||
expect(configsXyz).toHaveProperty('xyz_private_server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all servers from all caches (shared app, shared user, and private user)', async () => {
|
||||
const userId = 'user123';
|
||||
|
||||
// Add servers to all three caches
|
||||
await registry.sharedAppServers.add('app_server', testParsedConfig);
|
||||
await registry.sharedUserServers.add('user_server', testParsedConfig);
|
||||
await registry.addPrivateUserServer(userId, 'private_server', testParsedConfig);
|
||||
|
||||
// Verify all servers are accessible before reset
|
||||
const appConfigBefore = await registry.getServerConfig('app_server');
|
||||
const userConfigBefore = await registry.getServerConfig('user_server');
|
||||
const privateConfigBefore = await registry.getServerConfig('private_server', userId);
|
||||
const allConfigsBefore = await registry.getAllServerConfigs(userId);
|
||||
|
||||
expect(appConfigBefore).toEqual(testParsedConfig);
|
||||
expect(userConfigBefore).toEqual(testParsedConfig);
|
||||
expect(privateConfigBefore).toEqual(testParsedConfig);
|
||||
expect(Object.keys(allConfigsBefore)).toHaveLength(3);
|
||||
|
||||
// Reset everything
|
||||
await registry.reset();
|
||||
|
||||
// Verify all servers are cleared after reset
|
||||
const appConfigAfter = await registry.getServerConfig('app_server');
|
||||
const userConfigAfter = await registry.getServerConfig('user_server');
|
||||
const privateConfigAfter = await registry.getServerConfig('private_server', userId);
|
||||
const allConfigsAfter = await registry.getAllServerConfigs(userId);
|
||||
|
||||
expect(appConfigAfter).toBeUndefined();
|
||||
expect(userConfigAfter).toBeUndefined();
|
||||
expect(privateConfigAfter).toBeUndefined();
|
||||
expect(Object.keys(allConfigsAfter)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import * as t from '~/mcp/types';
|
||||
import { mcpServersRegistry as registry } from '~/mcp/registry/MCPServersRegistry';
|
||||
|
||||
/**
|
||||
* Unit tests for MCPServersRegistry using in-memory cache.
|
||||
* For integration tests using Redis-backed cache, see MCPServersRegistry.cache_integration.spec.ts
|
||||
*/
|
||||
describe('MCPServersRegistry', () => {
|
||||
const testParsedConfig: t.ParsedServerConfig = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['tools.js'],
|
||||
requiresOAuth: false,
|
||||
serverInstructions: 'Instructions for file_tools_server',
|
||||
tools: 'file_read, file_write',
|
||||
capabilities: '{"tools":{"listChanged":true}}',
|
||||
toolFunctions: {
|
||||
file_read_mcp_file_tools_server: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'file_read_mcp_file_tools_server',
|
||||
description: 'Read a file',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await registry.reset();
|
||||
});
|
||||
|
||||
describe('private user servers', () => {
|
||||
it('should add and remove private user server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
|
||||
// Add private user server
|
||||
await registry.addPrivateUserServer(userId, serverName, testParsedConfig);
|
||||
|
||||
// Verify server was added
|
||||
const retrievedConfig = await registry.getServerConfig(serverName, userId);
|
||||
expect(retrievedConfig).toEqual(testParsedConfig);
|
||||
|
||||
// Remove private user server
|
||||
await registry.removePrivateUserServer(userId, serverName);
|
||||
|
||||
// Verify server was removed
|
||||
const configAfterRemoval = await registry.getServerConfig(serverName, userId);
|
||||
expect(configAfterRemoval).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when adding duplicate private user server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
|
||||
await registry.addPrivateUserServer(userId, serverName, testParsedConfig);
|
||||
await expect(
|
||||
registry.addPrivateUserServer(userId, serverName, testParsedConfig),
|
||||
).rejects.toThrow(
|
||||
'Server "private_server" already exists in cache. Use update() to modify existing configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update an existing private user server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
const updatedConfig: t.ParsedServerConfig = {
|
||||
type: 'stdio',
|
||||
command: 'python',
|
||||
args: ['updated.py'],
|
||||
requiresOAuth: true,
|
||||
};
|
||||
|
||||
// Add private user server
|
||||
await registry.addPrivateUserServer(userId, serverName, testParsedConfig);
|
||||
|
||||
// Update the server config
|
||||
await registry.updatePrivateUserServer(userId, serverName, updatedConfig);
|
||||
|
||||
// Verify server was updated
|
||||
const retrievedConfig = await registry.getServerConfig(serverName, userId);
|
||||
expect(retrievedConfig).toEqual(updatedConfig);
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent server', async () => {
|
||||
const userId = 'user123';
|
||||
const serverName = 'private_server';
|
||||
|
||||
// Add a user cache first
|
||||
await registry.addPrivateUserServer(userId, 'other_server', testParsedConfig);
|
||||
|
||||
await expect(
|
||||
registry.updatePrivateUserServer(userId, serverName, testParsedConfig),
|
||||
).rejects.toThrow(
|
||||
'Server "private_server" does not exist in cache. Use add() to create new configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when updating server for non-existent user', async () => {
|
||||
const userId = 'nonexistent_user';
|
||||
const serverName = 'private_server';
|
||||
|
||||
await expect(
|
||||
registry.updatePrivateUserServer(userId, serverName, testParsedConfig),
|
||||
).rejects.toThrow('No private servers found for user "nonexistent_user".');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllServerConfigs', () => {
|
||||
it('should return correct servers based on userId', async () => {
|
||||
// Add servers to all three caches
|
||||
await registry.sharedAppServers.add('app_server', testParsedConfig);
|
||||
await registry.sharedUserServers.add('user_server', testParsedConfig);
|
||||
await registry.addPrivateUserServer('abc', 'abc_private_server', testParsedConfig);
|
||||
await registry.addPrivateUserServer('xyz', 'xyz_private_server', testParsedConfig);
|
||||
|
||||
// Without userId: should return only shared app + shared user servers
|
||||
const configsNoUser = await registry.getAllServerConfigs();
|
||||
expect(Object.keys(configsNoUser)).toHaveLength(2);
|
||||
expect(configsNoUser).toHaveProperty('app_server');
|
||||
expect(configsNoUser).toHaveProperty('user_server');
|
||||
|
||||
// With userId 'abc': should return shared app + shared user + abc's private servers
|
||||
const configsAbc = await registry.getAllServerConfigs('abc');
|
||||
expect(Object.keys(configsAbc)).toHaveLength(3);
|
||||
expect(configsAbc).toHaveProperty('app_server');
|
||||
expect(configsAbc).toHaveProperty('user_server');
|
||||
expect(configsAbc).toHaveProperty('abc_private_server');
|
||||
|
||||
// With userId 'xyz': should return shared app + shared user + xyz's private servers
|
||||
const configsXyz = await registry.getAllServerConfigs('xyz');
|
||||
expect(Object.keys(configsXyz)).toHaveLength(3);
|
||||
expect(configsXyz).toHaveProperty('app_server');
|
||||
expect(configsXyz).toHaveProperty('user_server');
|
||||
expect(configsXyz).toHaveProperty('xyz_private_server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all servers from all caches (shared app, shared user, and private user)', async () => {
|
||||
const userId = 'user123';
|
||||
|
||||
// Add servers to all three caches
|
||||
await registry.sharedAppServers.add('app_server', testParsedConfig);
|
||||
await registry.sharedUserServers.add('user_server', testParsedConfig);
|
||||
await registry.addPrivateUserServer(userId, 'private_server', testParsedConfig);
|
||||
|
||||
// Verify all servers are accessible before reset
|
||||
const appConfigBefore = await registry.getServerConfig('app_server');
|
||||
const userConfigBefore = await registry.getServerConfig('user_server');
|
||||
const privateConfigBefore = await registry.getServerConfig('private_server', userId);
|
||||
const allConfigsBefore = await registry.getAllServerConfigs(userId);
|
||||
|
||||
expect(appConfigBefore).toEqual(testParsedConfig);
|
||||
expect(userConfigBefore).toEqual(testParsedConfig);
|
||||
expect(privateConfigBefore).toEqual(testParsedConfig);
|
||||
expect(Object.keys(allConfigsBefore)).toHaveLength(3);
|
||||
|
||||
// Reset everything
|
||||
await registry.reset();
|
||||
|
||||
// Verify all servers are cleared after reset
|
||||
const appConfigAfter = await registry.getServerConfig('app_server');
|
||||
const userConfigAfter = await registry.getServerConfig('user_server');
|
||||
const privateConfigAfter = await registry.getServerConfig('private_server', userId);
|
||||
const allConfigsAfter = await registry.getAllServerConfigs(userId);
|
||||
|
||||
expect(appConfigAfter).toBeUndefined();
|
||||
expect(userConfigAfter).toBeUndefined();
|
||||
expect(privateConfigAfter).toBeUndefined();
|
||||
expect(Object.keys(allConfigsAfter)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { MCPConnection } from '~/mcp/connection';
|
||||
|
||||
/**
|
||||
* Creates a single mock MCP connection for testing.
|
||||
* The connection has a client with mocked methods that return server-specific data.
|
||||
* @param serverName - Name of the server to create mock connection for
|
||||
* @returns Mocked MCPConnection instance
|
||||
*/
|
||||
export function createMockConnection(serverName: string): jest.Mocked<MCPConnection> {
|
||||
const mockClient = {
|
||||
getInstructions: jest.fn().mockReturnValue(`instructions for ${serverName}`),
|
||||
getServerCapabilities: jest.fn().mockReturnValue({
|
||||
tools: { listChanged: true },
|
||||
resources: { listChanged: true },
|
||||
prompts: { get: `getPrompts for ${serverName}` },
|
||||
}),
|
||||
listTools: jest.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'listFiles',
|
||||
description: `Description for ${serverName}'s listFiles tool`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
client: mockClient,
|
||||
disconnect: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<MCPConnection>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates mock MCP connections for testing.
|
||||
* Each connection has a client with mocked methods that return server-specific data.
|
||||
* @param serverNames - Array of server names to create mock connections for
|
||||
* @returns Map of server names to mocked MCPConnection instances
|
||||
*/
|
||||
export function createMockConnectionsMap(
|
||||
serverNames: string[],
|
||||
): Map<string, jest.Mocked<MCPConnection>> {
|
||||
const mockConnections = new Map<string, jest.Mocked<MCPConnection>>();
|
||||
|
||||
serverNames.forEach((serverName) => {
|
||||
mockConnections.set(serverName, createMockConnection(serverName));
|
||||
});
|
||||
|
||||
return mockConnections;
|
||||
}
|
||||
26
packages/api/src/mcp/registry/cache/BaseRegistryCache.ts
vendored
Normal file
26
packages/api/src/mcp/registry/cache/BaseRegistryCache.ts
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type Keyv from 'keyv';
|
||||
import { isLeader } from '~/cluster';
|
||||
|
||||
/**
|
||||
* Base class for MCP registry caches that require distributed leader coordination.
|
||||
* Provides helper methods for leader-only operations and success validation.
|
||||
* All concrete implementations must provide their own Keyv cache instance.
|
||||
*/
|
||||
export abstract class BaseRegistryCache {
|
||||
protected readonly PREFIX = 'MCP::ServersRegistry';
|
||||
protected abstract readonly cache: Keyv;
|
||||
|
||||
protected async leaderCheck(action: string): Promise<void> {
|
||||
if (!(await isLeader())) throw new Error(`Only leader can ${action}.`);
|
||||
}
|
||||
|
||||
protected successCheck(action: string, success: boolean): true {
|
||||
if (!success) throw new Error(`Failed to ${action} in cache.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
await this.leaderCheck(`reset ${this.cache.namespace} cache`);
|
||||
await this.cache.clear();
|
||||
}
|
||||
}
|
||||
37
packages/api/src/mcp/registry/cache/RegistryStatusCache.ts
vendored
Normal file
37
packages/api/src/mcp/registry/cache/RegistryStatusCache.ts
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { standardCache } from '~/cache';
|
||||
import { BaseRegistryCache } from './BaseRegistryCache';
|
||||
|
||||
// Status keys
|
||||
const INITIALIZED = 'INITIALIZED';
|
||||
|
||||
/**
|
||||
* Cache for tracking MCP Servers Registry metadata and status across distributed instances.
|
||||
* Uses Redis-backed storage to coordinate state between leader and follower nodes.
|
||||
* Currently, tracks initialization status to ensure only the leader performs initialization
|
||||
* while followers wait for completion. Designed to be extended with additional registry
|
||||
* metadata as needed (e.g., last update timestamps, version info, health status).
|
||||
* This cache is only meant to be used internally by registry management components.
|
||||
*/
|
||||
class RegistryStatusCache extends BaseRegistryCache {
|
||||
protected readonly cache = standardCache(`${this.PREFIX}::Status`);
|
||||
|
||||
public async isInitialized(): Promise<boolean> {
|
||||
return (await this.get(INITIALIZED)) === true;
|
||||
}
|
||||
|
||||
public async setInitialized(value: boolean): Promise<void> {
|
||||
await this.set(INITIALIZED, value);
|
||||
}
|
||||
|
||||
private async get<T = unknown>(key: string): Promise<T | undefined> {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
private async set(key: string, value: string | number | boolean, ttl?: number): Promise<void> {
|
||||
await this.leaderCheck('set MCP Servers Registry status');
|
||||
const success = await this.cache.set(key, value, ttl);
|
||||
this.successCheck(`set status key "${key}"`, success);
|
||||
}
|
||||
}
|
||||
|
||||
export const registryStatusCache = new RegistryStatusCache();
|
||||
31
packages/api/src/mcp/registry/cache/ServerConfigsCacheFactory.ts
vendored
Normal file
31
packages/api/src/mcp/registry/cache/ServerConfigsCacheFactory.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { cacheConfig } from '~/cache';
|
||||
import { ServerConfigsCacheInMemory } from './ServerConfigsCacheInMemory';
|
||||
import { ServerConfigsCacheRedis } from './ServerConfigsCacheRedis';
|
||||
|
||||
export type ServerConfigsCache = ServerConfigsCacheInMemory | ServerConfigsCacheRedis;
|
||||
|
||||
/**
|
||||
* Factory for creating the appropriate ServerConfigsCache implementation based on deployment mode.
|
||||
* Automatically selects between in-memory and Redis-backed storage depending on USE_REDIS config.
|
||||
* In single-instance mode (USE_REDIS=false), returns lightweight in-memory cache.
|
||||
* In cluster mode (USE_REDIS=true), returns Redis-backed cache with distributed coordination.
|
||||
* Provides a unified interface regardless of the underlying storage mechanism.
|
||||
*/
|
||||
export class ServerConfigsCacheFactory {
|
||||
/**
|
||||
* Create a ServerConfigsCache instance.
|
||||
* Returns Redis implementation if Redis is configured, otherwise in-memory implementation.
|
||||
*
|
||||
* @param owner - The owner of the cache (e.g., 'user', 'global') - only used for Redis namespacing
|
||||
* @param leaderOnly - Whether operations should only be performed by the leader (only applies to Redis)
|
||||
* @returns ServerConfigsCache instance
|
||||
*/
|
||||
static create(owner: string, leaderOnly: boolean): ServerConfigsCache {
|
||||
if (cacheConfig.USE_REDIS) {
|
||||
return new ServerConfigsCacheRedis(owner, leaderOnly);
|
||||
}
|
||||
|
||||
// In-memory mode uses a simple Map - doesn't need owner/namespace
|
||||
return new ServerConfigsCacheInMemory();
|
||||
}
|
||||
}
|
||||
46
packages/api/src/mcp/registry/cache/ServerConfigsCacheInMemory.ts
vendored
Normal file
46
packages/api/src/mcp/registry/cache/ServerConfigsCacheInMemory.ts
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
|
||||
/**
|
||||
* In-memory implementation of MCP server configurations cache for single-instance deployments.
|
||||
* Uses a native JavaScript Map for fast, local storage without Redis dependencies.
|
||||
* Suitable for development environments or single-server production deployments.
|
||||
* Does not require leader checks or distributed coordination since data is instance-local.
|
||||
* Data is lost on server restart and not shared across multiple server instances.
|
||||
*/
|
||||
export class ServerConfigsCacheInMemory {
|
||||
private readonly cache: Map<string, ParsedServerConfig> = new Map();
|
||||
|
||||
public async add(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
if (this.cache.has(serverName))
|
||||
throw new Error(
|
||||
`Server "${serverName}" already exists in cache. Use update() to modify existing configs.`,
|
||||
);
|
||||
this.cache.set(serverName, config);
|
||||
}
|
||||
|
||||
public async update(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
if (!this.cache.has(serverName))
|
||||
throw new Error(
|
||||
`Server "${serverName}" does not exist in cache. Use add() to create new configs.`,
|
||||
);
|
||||
this.cache.set(serverName, config);
|
||||
}
|
||||
|
||||
public async remove(serverName: string): Promise<void> {
|
||||
if (!this.cache.delete(serverName)) {
|
||||
throw new Error(`Failed to remove server "${serverName}" in cache.`);
|
||||
}
|
||||
}
|
||||
|
||||
public async get(serverName: string): Promise<ParsedServerConfig | undefined> {
|
||||
return this.cache.get(serverName);
|
||||
}
|
||||
|
||||
public async getAll(): Promise<Record<string, ParsedServerConfig>> {
|
||||
return Object.fromEntries(this.cache);
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
80
packages/api/src/mcp/registry/cache/ServerConfigsCacheRedis.ts
vendored
Normal file
80
packages/api/src/mcp/registry/cache/ServerConfigsCacheRedis.ts
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type Keyv from 'keyv';
|
||||
import { fromPairs } from 'lodash';
|
||||
import { standardCache, keyvRedisClient } from '~/cache';
|
||||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
import { BaseRegistryCache } from './BaseRegistryCache';
|
||||
|
||||
/**
|
||||
* Redis-backed implementation of MCP server configurations cache for distributed deployments.
|
||||
* Stores server configs in Redis with namespace isolation by owner (App, User, or specific user ID).
|
||||
* Enables data sharing across multiple server instances in a cluster environment.
|
||||
* Supports optional leader-only write operations to prevent race conditions during initialization.
|
||||
* Data persists across server restarts and is accessible from any instance in the cluster.
|
||||
*/
|
||||
export class ServerConfigsCacheRedis extends BaseRegistryCache {
|
||||
protected readonly cache: Keyv;
|
||||
private readonly owner: string;
|
||||
private readonly leaderOnly: boolean;
|
||||
|
||||
constructor(owner: string, leaderOnly: boolean) {
|
||||
super();
|
||||
this.owner = owner;
|
||||
this.leaderOnly = leaderOnly;
|
||||
this.cache = standardCache(`${this.PREFIX}::Servers::${owner}`);
|
||||
}
|
||||
|
||||
public async add(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
if (this.leaderOnly) await this.leaderCheck(`add ${this.owner} MCP servers`);
|
||||
const exists = await this.cache.has(serverName);
|
||||
if (exists)
|
||||
throw new Error(
|
||||
`Server "${serverName}" already exists in cache. Use update() to modify existing configs.`,
|
||||
);
|
||||
const success = await this.cache.set(serverName, config);
|
||||
this.successCheck(`add ${this.owner} server "${serverName}"`, success);
|
||||
}
|
||||
|
||||
public async update(serverName: string, config: ParsedServerConfig): Promise<void> {
|
||||
if (this.leaderOnly) await this.leaderCheck(`update ${this.owner} MCP servers`);
|
||||
const exists = await this.cache.has(serverName);
|
||||
if (!exists)
|
||||
throw new Error(
|
||||
`Server "${serverName}" does not exist in cache. Use add() to create new configs.`,
|
||||
);
|
||||
const success = await this.cache.set(serverName, config);
|
||||
this.successCheck(`update ${this.owner} server "${serverName}"`, success);
|
||||
}
|
||||
|
||||
public async remove(serverName: string): Promise<void> {
|
||||
if (this.leaderOnly) await this.leaderCheck(`remove ${this.owner} MCP servers`);
|
||||
const success = await this.cache.delete(serverName);
|
||||
this.successCheck(`remove ${this.owner} server "${serverName}"`, success);
|
||||
}
|
||||
|
||||
public async get(serverName: string): Promise<ParsedServerConfig | undefined> {
|
||||
return this.cache.get(serverName);
|
||||
}
|
||||
|
||||
public async getAll(): Promise<Record<string, ParsedServerConfig>> {
|
||||
// Use Redis SCAN iterator directly (non-blocking, production-ready)
|
||||
// Note: Keyv uses a single colon ':' between namespace and key, even if GLOBAL_PREFIX_SEPARATOR is '::'
|
||||
const pattern = `*${this.cache.namespace}:*`;
|
||||
const entries: Array<[string, ParsedServerConfig]> = [];
|
||||
|
||||
// Use scanIterator from Redis client
|
||||
if (keyvRedisClient && 'scanIterator' in keyvRedisClient) {
|
||||
for await (const key of keyvRedisClient.scanIterator({ MATCH: pattern })) {
|
||||
// Extract the actual key name (last part after final colon)
|
||||
// Full key format: "prefix::namespace:keyName"
|
||||
const lastColonIndex = key.lastIndexOf(':');
|
||||
const keyName = key.substring(lastColonIndex + 1);
|
||||
const value = await this.cache.get(keyName);
|
||||
if (value) {
|
||||
entries.push([keyName, value as ParsedServerConfig]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fromPairs(entries);
|
||||
}
|
||||
}
|
||||
73
packages/api/src/mcp/registry/cache/__tests__/RegistryStatusCache.cache_integration.spec.ts
vendored
Normal file
73
packages/api/src/mcp/registry/cache/__tests__/RegistryStatusCache.cache_integration.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { expect } from '@playwright/test';
|
||||
|
||||
describe('RegistryStatusCache Integration Tests', () => {
|
||||
let registryStatusCache: typeof import('../RegistryStatusCache').registryStatusCache;
|
||||
let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient'];
|
||||
let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection;
|
||||
let leaderInstance: InstanceType<typeof import('~/cluster/LeaderElection').LeaderElection>;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment variables for Redis (only if not already set)
|
||||
process.env.USE_REDIS = process.env.USE_REDIS ?? 'true';
|
||||
process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379';
|
||||
process.env.REDIS_KEY_PREFIX =
|
||||
process.env.REDIS_KEY_PREFIX ?? 'RegistryStatusCache-IntegrationTest';
|
||||
|
||||
// Import modules after setting env vars
|
||||
const statusCacheModule = await import('../RegistryStatusCache');
|
||||
const redisClients = await import('~/cache/redisClients');
|
||||
const leaderElectionModule = await import('~/cluster/LeaderElection');
|
||||
|
||||
registryStatusCache = statusCacheModule.registryStatusCache;
|
||||
keyvRedisClient = redisClients.keyvRedisClient;
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
|
||||
// Ensure Redis is connected
|
||||
if (!keyvRedisClient) throw new Error('Redis client is not initialized');
|
||||
|
||||
// Wait for Redis to be ready
|
||||
if (!keyvRedisClient.isOpen) await keyvRedisClient.connect();
|
||||
|
||||
// Become leader so we can perform write operations
|
||||
leaderInstance = new LeaderElection();
|
||||
const isLeader = await leaderInstance.isLeader();
|
||||
expect(isLeader).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: clear all test keys from Redis
|
||||
if (keyvRedisClient) {
|
||||
const pattern = '*RegistryStatusCache-IntegrationTest*';
|
||||
if ('scanIterator' in keyvRedisClient) {
|
||||
for await (const key of keyvRedisClient.scanIterator({ MATCH: pattern })) {
|
||||
await keyvRedisClient.del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Resign as leader
|
||||
if (leaderInstance) await leaderInstance.resign();
|
||||
|
||||
// Close Redis connection
|
||||
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
|
||||
});
|
||||
|
||||
describe('Initialization status tracking', () => {
|
||||
it('should return false for isInitialized when not set', async () => {
|
||||
const initialized = await registryStatusCache.isInitialized();
|
||||
expect(initialized).toBe(false);
|
||||
});
|
||||
|
||||
it('should set and get initialized status', async () => {
|
||||
await registryStatusCache.setInitialized(true);
|
||||
const initialized = await registryStatusCache.isInitialized();
|
||||
expect(initialized).toBe(true);
|
||||
|
||||
await registryStatusCache.setInitialized(false);
|
||||
const uninitialized = await registryStatusCache.isInitialized();
|
||||
expect(uninitialized).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheFactory.test.ts
vendored
Normal file
70
packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheFactory.test.ts
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { ServerConfigsCacheFactory } from '../ServerConfigsCacheFactory';
|
||||
import { ServerConfigsCacheInMemory } from '../ServerConfigsCacheInMemory';
|
||||
import { ServerConfigsCacheRedis } from '../ServerConfigsCacheRedis';
|
||||
import { cacheConfig } from '~/cache';
|
||||
|
||||
// Mock the cache implementations
|
||||
jest.mock('../ServerConfigsCacheInMemory');
|
||||
jest.mock('../ServerConfigsCacheRedis');
|
||||
|
||||
// Mock the cache config module
|
||||
jest.mock('~/cache', () => ({
|
||||
cacheConfig: {
|
||||
USE_REDIS: false,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ServerConfigsCacheFactory', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create()', () => {
|
||||
it('should return ServerConfigsCacheRedis when USE_REDIS is true', () => {
|
||||
// Arrange
|
||||
cacheConfig.USE_REDIS = true;
|
||||
|
||||
// Act
|
||||
const cache = ServerConfigsCacheFactory.create('TestOwner', true);
|
||||
|
||||
// Assert
|
||||
expect(cache).toBeInstanceOf(ServerConfigsCacheRedis);
|
||||
expect(ServerConfigsCacheRedis).toHaveBeenCalledWith('TestOwner', true);
|
||||
});
|
||||
|
||||
it('should return ServerConfigsCacheInMemory when USE_REDIS is false', () => {
|
||||
// Arrange
|
||||
cacheConfig.USE_REDIS = false;
|
||||
|
||||
// Act
|
||||
const cache = ServerConfigsCacheFactory.create('TestOwner', false);
|
||||
|
||||
// Assert
|
||||
expect(cache).toBeInstanceOf(ServerConfigsCacheInMemory);
|
||||
expect(ServerConfigsCacheInMemory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass correct parameters to ServerConfigsCacheRedis', () => {
|
||||
// Arrange
|
||||
cacheConfig.USE_REDIS = true;
|
||||
|
||||
// Act
|
||||
ServerConfigsCacheFactory.create('App', true);
|
||||
|
||||
// Assert
|
||||
expect(ServerConfigsCacheRedis).toHaveBeenCalledWith('App', true);
|
||||
});
|
||||
|
||||
it('should create ServerConfigsCacheInMemory without parameters when USE_REDIS is false', () => {
|
||||
// Arrange
|
||||
cacheConfig.USE_REDIS = false;
|
||||
|
||||
// Act
|
||||
ServerConfigsCacheFactory.create('User', false);
|
||||
|
||||
// Assert
|
||||
// In-memory cache doesn't use owner/leaderOnly parameters
|
||||
expect(ServerConfigsCacheInMemory).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts
vendored
Normal file
173
packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts
vendored
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
|
||||
describe('ServerConfigsCacheInMemory Integration Tests', () => {
|
||||
let ServerConfigsCacheInMemory: typeof import('../ServerConfigsCacheInMemory').ServerConfigsCacheInMemory;
|
||||
let cache: InstanceType<
|
||||
typeof import('../ServerConfigsCacheInMemory').ServerConfigsCacheInMemory
|
||||
>;
|
||||
|
||||
// Test data
|
||||
const mockConfig1: ParsedServerConfig = {
|
||||
command: 'node',
|
||||
args: ['server1.js'],
|
||||
env: { TEST: 'value1' },
|
||||
};
|
||||
|
||||
const mockConfig2: ParsedServerConfig = {
|
||||
command: 'python',
|
||||
args: ['server2.py'],
|
||||
env: { TEST: 'value2' },
|
||||
};
|
||||
|
||||
const mockConfig3: ParsedServerConfig = {
|
||||
command: 'node',
|
||||
args: ['server3.js'],
|
||||
url: 'http://localhost:3000',
|
||||
requiresOAuth: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Import modules
|
||||
const cacheModule = await import('../ServerConfigsCacheInMemory');
|
||||
ServerConfigsCacheInMemory = cacheModule.ServerConfigsCacheInMemory;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh instance for each test
|
||||
cache = new ServerConfigsCacheInMemory();
|
||||
});
|
||||
|
||||
describe('add and get operations', () => {
|
||||
it('should add and retrieve a server config', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
const result = await cache.get('server1');
|
||||
expect(result).toEqual(mockConfig1);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent server', async () => {
|
||||
const result = await cache.get('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when adding duplicate server', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await expect(cache.add('server1', mockConfig2)).rejects.toThrow(
|
||||
'Server "server1" already exists in cache. Use update() to modify existing configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple server configs', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
await cache.add('server3', mockConfig3);
|
||||
|
||||
const result1 = await cache.get('server1');
|
||||
const result2 = await cache.get('server2');
|
||||
const result3 = await cache.get('server3');
|
||||
|
||||
expect(result1).toEqual(mockConfig1);
|
||||
expect(result2).toEqual(mockConfig2);
|
||||
expect(result3).toEqual(mockConfig3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll operation', () => {
|
||||
it('should return empty object when no servers exist', async () => {
|
||||
const result = await cache.getAll();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return all server configs', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
await cache.add('server3', mockConfig3);
|
||||
|
||||
const result = await cache.getAll();
|
||||
expect(result).toEqual({
|
||||
server1: mockConfig1,
|
||||
server2: mockConfig2,
|
||||
server3: mockConfig3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reflect updates in getAll', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
|
||||
let result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
|
||||
await cache.add('server3', mockConfig3);
|
||||
result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(3);
|
||||
expect(result.server3).toEqual(mockConfig3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update operation', () => {
|
||||
it('should update an existing server config', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
expect(await cache.get('server1')).toEqual(mockConfig1);
|
||||
|
||||
await cache.update('server1', mockConfig2);
|
||||
const result = await cache.get('server1');
|
||||
expect(result).toEqual(mockConfig2);
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent server', async () => {
|
||||
await expect(cache.update('non-existent', mockConfig1)).rejects.toThrow(
|
||||
'Server "non-existent" does not exist in cache. Use add() to create new configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reflect updates in getAll', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
|
||||
await cache.update('server1', mockConfig3);
|
||||
const result = await cache.getAll();
|
||||
expect(result.server1).toEqual(mockConfig3);
|
||||
expect(result.server2).toEqual(mockConfig2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove operation', () => {
|
||||
it('should remove an existing server config', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
expect(await cache.get('server1')).toEqual(mockConfig1);
|
||||
|
||||
await cache.remove('server1');
|
||||
expect(await cache.get('server1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when removing non-existent server', async () => {
|
||||
await expect(cache.remove('non-existent')).rejects.toThrow(
|
||||
'Failed to remove server "non-existent" in cache.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove server from getAll results', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
|
||||
let result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
|
||||
await cache.remove('server1');
|
||||
result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(1);
|
||||
expect(result.server1).toBeUndefined();
|
||||
expect(result.server2).toEqual(mockConfig2);
|
||||
});
|
||||
|
||||
it('should allow re-adding a removed server', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.remove('server1');
|
||||
await cache.add('server1', mockConfig3);
|
||||
|
||||
const result = await cache.get('server1');
|
||||
expect(result).toEqual(mockConfig3);
|
||||
});
|
||||
});
|
||||
});
|
||||
278
packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedis.cache_integration.spec.ts
vendored
Normal file
278
packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheRedis.cache_integration.spec.ts
vendored
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import { ParsedServerConfig } from '~/mcp/types';
|
||||
|
||||
describe('ServerConfigsCacheRedis Integration Tests', () => {
|
||||
let ServerConfigsCacheRedis: typeof import('../ServerConfigsCacheRedis').ServerConfigsCacheRedis;
|
||||
let keyvRedisClient: Awaited<typeof import('~/cache/redisClients')>['keyvRedisClient'];
|
||||
let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection;
|
||||
let checkIsLeader: () => Promise<boolean>;
|
||||
let cache: InstanceType<typeof import('../ServerConfigsCacheRedis').ServerConfigsCacheRedis>;
|
||||
|
||||
// Test data
|
||||
const mockConfig1: ParsedServerConfig = {
|
||||
command: 'node',
|
||||
args: ['server1.js'],
|
||||
env: { TEST: 'value1' },
|
||||
};
|
||||
|
||||
const mockConfig2: ParsedServerConfig = {
|
||||
command: 'python',
|
||||
args: ['server2.py'],
|
||||
env: { TEST: 'value2' },
|
||||
};
|
||||
|
||||
const mockConfig3: ParsedServerConfig = {
|
||||
command: 'node',
|
||||
args: ['server3.js'],
|
||||
url: 'http://localhost:3000',
|
||||
requiresOAuth: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment variables for Redis (only if not already set)
|
||||
process.env.USE_REDIS = process.env.USE_REDIS ?? 'true';
|
||||
process.env.REDIS_URI = process.env.REDIS_URI ?? 'redis://127.0.0.1:6379';
|
||||
process.env.REDIS_KEY_PREFIX =
|
||||
process.env.REDIS_KEY_PREFIX ?? 'ServerConfigsCacheRedis-IntegrationTest';
|
||||
|
||||
// Import modules after setting env vars
|
||||
const cacheModule = await import('../ServerConfigsCacheRedis');
|
||||
const redisClients = await import('~/cache/redisClients');
|
||||
const leaderElectionModule = await import('~/cluster/LeaderElection');
|
||||
const clusterModule = await import('~/cluster');
|
||||
|
||||
ServerConfigsCacheRedis = cacheModule.ServerConfigsCacheRedis;
|
||||
keyvRedisClient = redisClients.keyvRedisClient;
|
||||
LeaderElection = leaderElectionModule.LeaderElection;
|
||||
checkIsLeader = clusterModule.isLeader;
|
||||
|
||||
// Ensure Redis is connected
|
||||
if (!keyvRedisClient) throw new Error('Redis client is not initialized');
|
||||
|
||||
// Wait for Redis to be ready
|
||||
if (!keyvRedisClient.isOpen) await keyvRedisClient.connect();
|
||||
|
||||
// Clear any existing leader key to ensure clean state
|
||||
await keyvRedisClient.del(LeaderElection.LEADER_KEY);
|
||||
|
||||
// Become leader so we can perform write operations (using default election instance)
|
||||
const isLeader = await checkIsLeader();
|
||||
expect(isLeader).toBe(true);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh instance for each test with leaderOnly=true
|
||||
cache = new ServerConfigsCacheRedis('test-user', true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up: clear all test keys from Redis
|
||||
if (keyvRedisClient) {
|
||||
const pattern = '*ServerConfigsCacheRedis-IntegrationTest*';
|
||||
if ('scanIterator' in keyvRedisClient) {
|
||||
for await (const key of keyvRedisClient.scanIterator({ MATCH: pattern })) {
|
||||
await keyvRedisClient.del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clear leader key to allow other tests to become leader
|
||||
if (keyvRedisClient) await keyvRedisClient.del(LeaderElection.LEADER_KEY);
|
||||
|
||||
// Close Redis connection
|
||||
if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect();
|
||||
});
|
||||
|
||||
describe('add and get operations', () => {
|
||||
it('should add and retrieve a server config', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
const result = await cache.get('server1');
|
||||
expect(result).toEqual(mockConfig1);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent server', async () => {
|
||||
const result = await cache.get('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when adding duplicate server', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await expect(cache.add('server1', mockConfig2)).rejects.toThrow(
|
||||
'Server "server1" already exists in cache. Use update() to modify existing configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple server configs', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
await cache.add('server3', mockConfig3);
|
||||
|
||||
const result1 = await cache.get('server1');
|
||||
const result2 = await cache.get('server2');
|
||||
const result3 = await cache.get('server3');
|
||||
|
||||
expect(result1).toEqual(mockConfig1);
|
||||
expect(result2).toEqual(mockConfig2);
|
||||
expect(result3).toEqual(mockConfig3);
|
||||
});
|
||||
|
||||
it('should isolate caches by owner namespace', async () => {
|
||||
const userCache = new ServerConfigsCacheRedis('user1', true);
|
||||
const globalCache = new ServerConfigsCacheRedis('global', true);
|
||||
|
||||
await userCache.add('server1', mockConfig1);
|
||||
await globalCache.add('server1', mockConfig2);
|
||||
|
||||
const userResult = await userCache.get('server1');
|
||||
const globalResult = await globalCache.get('server1');
|
||||
|
||||
expect(userResult).toEqual(mockConfig1);
|
||||
expect(globalResult).toEqual(mockConfig2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll operation', () => {
|
||||
it('should return empty object when no servers exist', async () => {
|
||||
const result = await cache.getAll();
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return all server configs', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
await cache.add('server3', mockConfig3);
|
||||
|
||||
const result = await cache.getAll();
|
||||
expect(result).toEqual({
|
||||
server1: mockConfig1,
|
||||
server2: mockConfig2,
|
||||
server3: mockConfig3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reflect updates in getAll', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
|
||||
let result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
|
||||
await cache.add('server3', mockConfig3);
|
||||
result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(3);
|
||||
expect(result.server3).toEqual(mockConfig3);
|
||||
});
|
||||
|
||||
it('should only return configs for the specific owner', async () => {
|
||||
const userCache = new ServerConfigsCacheRedis('user1', true);
|
||||
const globalCache = new ServerConfigsCacheRedis('global', true);
|
||||
|
||||
await userCache.add('server1', mockConfig1);
|
||||
await userCache.add('server2', mockConfig2);
|
||||
await globalCache.add('server3', mockConfig3);
|
||||
|
||||
const userResult = await userCache.getAll();
|
||||
const globalResult = await globalCache.getAll();
|
||||
|
||||
expect(Object.keys(userResult).length).toBe(2);
|
||||
expect(Object.keys(globalResult).length).toBe(1);
|
||||
expect(userResult.server1).toEqual(mockConfig1);
|
||||
expect(userResult.server3).toBeUndefined();
|
||||
expect(globalResult.server3).toEqual(mockConfig3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update operation', () => {
|
||||
it('should update an existing server config', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
expect(await cache.get('server1')).toEqual(mockConfig1);
|
||||
|
||||
await cache.update('server1', mockConfig2);
|
||||
const result = await cache.get('server1');
|
||||
expect(result).toEqual(mockConfig2);
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent server', async () => {
|
||||
await expect(cache.update('non-existent', mockConfig1)).rejects.toThrow(
|
||||
'Server "non-existent" does not exist in cache. Use add() to create new configs.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reflect updates in getAll', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
|
||||
await cache.update('server1', mockConfig3);
|
||||
const result = await cache.getAll();
|
||||
expect(result.server1).toEqual(mockConfig3);
|
||||
expect(result.server2).toEqual(mockConfig2);
|
||||
});
|
||||
|
||||
it('should only update in the specific owner namespace', async () => {
|
||||
const userCache = new ServerConfigsCacheRedis('user1', true);
|
||||
const globalCache = new ServerConfigsCacheRedis('global', true);
|
||||
|
||||
await userCache.add('server1', mockConfig1);
|
||||
await globalCache.add('server1', mockConfig2);
|
||||
|
||||
await userCache.update('server1', mockConfig3);
|
||||
|
||||
expect(await userCache.get('server1')).toEqual(mockConfig3);
|
||||
expect(await globalCache.get('server1')).toEqual(mockConfig2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove operation', () => {
|
||||
it('should remove an existing server config', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
expect(await cache.get('server1')).toEqual(mockConfig1);
|
||||
|
||||
await cache.remove('server1');
|
||||
expect(await cache.get('server1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when removing non-existent server', async () => {
|
||||
await expect(cache.remove('non-existent')).rejects.toThrow(
|
||||
'Failed to remove test-user server "non-existent"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove server from getAll results', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.add('server2', mockConfig2);
|
||||
|
||||
let result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
|
||||
await cache.remove('server1');
|
||||
result = await cache.getAll();
|
||||
expect(Object.keys(result).length).toBe(1);
|
||||
expect(result.server1).toBeUndefined();
|
||||
expect(result.server2).toEqual(mockConfig2);
|
||||
});
|
||||
|
||||
it('should allow re-adding a removed server', async () => {
|
||||
await cache.add('server1', mockConfig1);
|
||||
await cache.remove('server1');
|
||||
await cache.add('server1', mockConfig3);
|
||||
|
||||
const result = await cache.get('server1');
|
||||
expect(result).toEqual(mockConfig3);
|
||||
});
|
||||
|
||||
it('should only remove from the specific owner namespace', async () => {
|
||||
const userCache = new ServerConfigsCacheRedis('user1', true);
|
||||
const globalCache = new ServerConfigsCacheRedis('global', true);
|
||||
|
||||
await userCache.add('server1', mockConfig1);
|
||||
await globalCache.add('server1', mockConfig2);
|
||||
|
||||
await userCache.remove('server1');
|
||||
|
||||
expect(await userCache.get('server1')).toBeUndefined();
|
||||
expect(await globalCache.get('server1')).toEqual(mockConfig2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue