🧭 fix: Make MCP Catalog Redis Cluster-Safe (#14717)

* fix: make MCP catalog Redis startup cluster-safe

* fix: stabilize Redis readiness gate

* fix: type Redis readiness export

* style: apply canonical import order
This commit is contained in:
Danny Avila 2026-08-09 06:59:29 -04:00 committed by GitHub
parent 92d4705f79
commit 1bd4455c2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 160 additions and 24 deletions

View file

@ -37,6 +37,7 @@ const {
updateInterfacePermissions,
configureMessageFilterRegexValidator,
configureFileConfigRegexEngine,
waitForKeyvRedisClient,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const {
@ -115,6 +116,7 @@ const configureGenerationStreams = () => {
};
const startServer = async () => {
await waitForKeyvRedisClient();
const { metricsMiddleware, metricsRouter } = createMetrics();
if (!process.env.METRICS_SECRET) {
logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests');

View file

@ -110,6 +110,16 @@ describe('Telemetry wiring', () => {
describe('Startup readiness wiring', () => {
const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
it('awaits the shared Redis client before startup cache access', () => {
const redisReadyIndex = source.indexOf('await waitForKeyvRedisClient();');
const connectDbIndex = source.indexOf('await connectDb();');
const appConfigIndex = source.indexOf('await getAppConfig({ baseOnly: true });');
expect(redisReadyIndex).toBeGreaterThan(-1);
expect(connectDbIndex).toBeGreaterThan(redisReadyIndex);
expect(appConfigIndex).toBeGreaterThan(redisReadyIndex);
});
it('configures generation streams before the server accepts requests', () => {
const streamConfigIndex = source.indexOf('configureGenerationStreams();');
const listenIndex = source.indexOf('const server = app.listen');

View file

@ -8,13 +8,20 @@ const mockRedisClient = {
const mockKeyvRedisClient = {
eval: jest.fn(),
};
let mockRedisReadyPromise = Promise.resolve();
const mockRedisReady = {
then: (...args) => mockRedisReadyPromise.then(...args),
};
const mockWaitForRedis = jest.fn(() => mockRedisReady);
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
cacheConfig: { FORCED_IN_MEMORY_CACHE_NAMESPACES: [] },
evalKeyvRedisScript: (...args) => mockKeyvRedisClient.eval(...args),
mcpConfig: { USER_CONNECTION_IDLE_TIMEOUT: 15 * 60 * 1000 },
ioredisClient: mockRedisClient,
keyvRedisClient: mockKeyvRedisClient,
waitForKeyvRedisClient: mockWaitForRedis,
}));
jest.mock('@librechat/data-schemas', () => ({
@ -47,6 +54,7 @@ describe('global tool cache write lock', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRedisReadyPromise = Promise.resolve();
mockRedisClient.set.mockResolvedValue('OK');
mockRedisClient.eval.mockResolvedValue(1);
mockKeyvRedisClient.eval.mockResolvedValue(1);
@ -54,6 +62,43 @@ describe('global tool cache write lock', () => {
mockCache.delete.mockResolvedValue(true);
});
it('waits for the shared Redis client before accessing the catalog', async () => {
mockCache.get.mockResolvedValue(null);
let resolveRedisReady;
mockRedisReadyPromise = new Promise((resolve) => {
resolveRedisReady = resolve;
});
const update = updateCachedGlobalTools(() => ({ builtin: {} }));
await Promise.resolve();
expect(mockRedisClient.set).not.toHaveBeenCalled();
expect(mockCache.get).not.toHaveBeenCalled();
resolveRedisReady();
await expect(update).resolves.toBeUndefined();
expect(mockRedisClient.set).toHaveBeenCalledTimes(1);
expect(mockCache.get).toHaveBeenCalledWith('tools:global');
});
it('waits for the shared Redis client before reading the catalog', async () => {
mockCache.get.mockResolvedValue({ builtin: {} });
let resolveRedisReady;
mockRedisReadyPromise = new Promise((resolve) => {
resolveRedisReady = resolve;
});
const read = getCachedTools();
await Promise.resolve();
expect(mockCache.get).not.toHaveBeenCalled();
resolveRedisReady();
await expect(read).resolves.toEqual({ builtin: {} });
expect(mockCache.get).toHaveBeenCalledWith('tools:global');
});
it('acquires and safely releases the Redis lock around an aggregate update', async () => {
const operation = jest.fn().mockResolvedValue('updated');

View file

@ -1,8 +1,10 @@
const { CacheKeys } = require('librechat-data-provider');
const {
cacheConfig,
evalKeyvRedisScript,
ioredisClient,
keyvRedisClient,
waitForKeyvRedisClient,
mcpConfig,
ToolCacheKeys,
createMCPCatalogStore,
@ -12,7 +14,8 @@ const getLogStores = require('~/cache/getLogStores');
const store = createMCPCatalogStore({
cacheConfig,
ioredisClient,
keyvRedisClient,
keyvRedisClient: keyvRedisClient ? { eval: evalKeyvRedisScript } : null,
waitForRedis: waitForKeyvRedisClient,
userConnectionIdleTimeout: mcpConfig.USER_CONNECTION_IDLE_TIMEOUT,
getCache: () => getLogStores(CacheKeys.TOOL_CACHE),
});