🔄 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

* 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:
Theo N. Truong 2025-10-31 13:00:21 -06:00 committed by GitHub
parent 961f87cfda
commit ce7e6edad8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 3116 additions and 1150 deletions

View file

@ -15,6 +15,10 @@ jest.mock('@librechat/api', () => ({
storeTokens: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
mcpServersRegistry: {
getServerConfig: jest.fn(),
getOAuthServers: jest.fn(),
},
}));
jest.mock('@librechat/data-schemas', () => ({
@ -115,7 +119,7 @@ describe('MCP Routes', () => {
});
describe('GET /:serverName/oauth/initiate', () => {
const { MCPOAuthHandler } = require('@librechat/api');
const { MCPOAuthHandler, mcpServersRegistry } = require('@librechat/api');
const { getLogStores } = require('~/cache');
it('should initiate OAuth flow successfully', async () => {
@ -128,13 +132,9 @@ describe('MCP Routes', () => {
}),
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
mcpServersRegistry.getServerConfig.mockResolvedValue({});
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
authorizationUrl: 'https://oauth.example.com/auth',
@ -288,6 +288,7 @@ describe('MCP Routes', () => {
});
it('should handle OAuth callback successfully', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@ -307,6 +308,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@ -321,7 +323,6 @@ describe('MCP Routes', () => {
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -379,6 +380,7 @@ describe('MCP Routes', () => {
});
it('should handle system-level OAuth completion', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@ -398,14 +400,10 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code',
state: 'test-flow-id',
@ -417,6 +415,7 @@ describe('MCP Routes', () => {
});
it('should handle reconnection failure after OAuth', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@ -436,12 +435,12 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockMcpManager = {
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -461,6 +460,7 @@ describe('MCP Routes', () => {
});
it('should redirect to error page if token storage fails', async () => {
const { mcpServersRegistry } = require('@librechat/api');
const mockFlowManager = {
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
@ -480,6 +480,7 @@ describe('MCP Routes', () => {
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
mcpServersRegistry.getServerConfig.mockResolvedValue({});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
@ -730,12 +731,14 @@ describe('MCP Routes', () => {
});
describe('POST /:serverName/reinitialize', () => {
const { mcpServersRegistry } = require('@librechat/api');
it('should return 404 when server is not found in configuration', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue(null),
disconnectUserConnection: jest.fn().mockResolvedValue(),
};
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@ -750,9 +753,6 @@ describe('MCP Routes', () => {
it('should handle OAuth requirement during reinitialize', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {},
}),
disconnectUserConnection: jest.fn().mockResolvedValue(),
mcpConfigs: {},
getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
@ -763,6 +763,9 @@ describe('MCP Routes', () => {
}),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: {},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@ -788,12 +791,12 @@ describe('MCP Routes', () => {
it('should return 500 when reinitialize fails with non-OAuth error', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
disconnectUserConnection: jest.fn().mockResolvedValue(),
mcpConfigs: {},
getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@ -809,11 +812,12 @@ describe('MCP Routes', () => {
it('should return 500 when unexpected error occurs', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockImplementation(() => {
throw new Error('Config loading failed');
}),
disconnectUserConnection: jest.fn(),
};
mcpServersRegistry.getServerConfig.mockImplementation(() => {
throw new Error('Config loading failed');
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).post('/api/mcp/test-server/reinitialize');
@ -846,11 +850,11 @@ describe('MCP Routes', () => {
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({ endpoint: 'http://test-server.com' }),
disconnectUserConnection: jest.fn().mockResolvedValue(),
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({ endpoint: 'http://test-server.com' });
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@ -891,16 +895,16 @@ describe('MCP Routes', () => {
};
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
endpoint: 'http://test-server.com',
customUserVars: {
API_KEY: 'some-env-var',
},
}),
disconnectUserConnection: jest.fn().mockResolvedValue(),
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
};
mcpServersRegistry.getServerConfig.mockResolvedValue({
endpoint: 'http://test-server.com',
customUserVars: {
API_KEY: 'some-env-var',
},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
@ -1105,17 +1109,17 @@ describe('MCP Routes', () => {
describe('GET /:serverName/auth-values', () => {
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { mcpServersRegistry } = require('@librechat/api');
it('should return auth value flags for server', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {
API_KEY: 'some-env-var',
SECRET_TOKEN: 'another-env-var',
},
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: {
API_KEY: 'some-env-var',
SECRET_TOKEN: 'another-env-var',
},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
@ -1135,10 +1139,9 @@ describe('MCP Routes', () => {
});
it('should return 404 when server is not found in configuration', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue(null),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue(null);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
@ -1150,14 +1153,13 @@ describe('MCP Routes', () => {
});
it('should handle errors when checking auth values', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: {
API_KEY: 'some-env-var',
},
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: {
API_KEY: 'some-env-var',
},
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
@ -1174,12 +1176,11 @@ describe('MCP Routes', () => {
});
it('should return 500 when auth values check throws unexpected error', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockImplementation(() => {
throw new Error('Config loading failed');
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockImplementation(() => {
throw new Error('Config loading failed');
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/auth-values');
@ -1189,12 +1190,11 @@ describe('MCP Routes', () => {
});
it('should handle customUserVars that is not an object', async () => {
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({
customUserVars: 'not-an-object',
}),
};
const mockMcpManager = {};
mcpServersRegistry.getServerConfig.mockResolvedValue({
customUserVars: 'not-an-object',
});
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
const response = await request(app).get('/api/mcp/test-server/auth-values');
@ -1221,7 +1221,7 @@ describe('MCP Routes', () => {
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
@ -1239,6 +1239,7 @@ describe('MCP Routes', () => {
});
MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockFlowManager = {
completeFlow: jest.fn(),
@ -1249,7 +1250,6 @@ describe('MCP Routes', () => {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
}),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -1264,7 +1264,7 @@ describe('MCP Routes', () => {
it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
const { getCachedTools } = require('~/server/services/Config');
getCachedTools.mockResolvedValue(null);
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const { MCPOAuthHandler, MCPTokenStorage, mcpServersRegistry } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
@ -1290,6 +1290,7 @@ describe('MCP Routes', () => {
});
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mcpServersRegistry.getServerConfig.mockResolvedValue({});
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
@ -1297,7 +1298,6 @@ describe('MCP Routes', () => {
.fn()
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
}),
getRawConfig: jest.fn().mockReturnValue({}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);