📡 fix: Refresh MCP Tools After List-Changed Notifications (#14686)

* fix(mcp): handle dynamic tool list changes

Co-authored-by: Pascal Garber <pascal@artandcode.studio>

* test(mcp): fix CI validation

* fix(mcp): keep dynamic tool catalogs live

* fix(mcp): harden dynamic catalog lifecycle

* test(mcp): use typed startup connection

* test(mcp): isolate dynamic e2e fixtures

* fix(mcp): refresh tools after reconnect

* fix(mcp): close dynamic catalog cache gaps

* test(mcp): update OAuth connection mocks

* fix(mcp): preserve app snapshot ownership

* style(mcp): sort connection imports

* fix(mcp): close review race conditions

* fix(mcp): preserve cache ownership edges

* fix(mcp): harden recovery lifecycle

* fix(mcp): guard tool-less app refresh

* fix(mcp): fence distributed cache races

* fix(mcp): retire stale connection state

* fix(mcp): keep tool snapshots authoritative

* fix(mcp): fence stale app tool publications

* style(mcp): sort repository test imports

* test(mcp): mock empty startup publication

* fix(mcp): preserve app publication generations

* fix(mcp): harden publication recovery races

* fix(mcp): address tool catalogs by runtime config

* fix(mcp): load scoped catalogs for assistant writes

* fix(mcp): harden catalog publication recovery

* fix(mcp): serialize forced connection replacement

* fix(mcp): serialize ordinary creation with replacements

* fix(mcp): harden catalog fallback boundaries

* fix(mcp): close lifecycle fencing gaps

* fix(mcp): preserve catalog authority on failures

* fix(mcp): compensate failed catalog mutations

* fix(mcp): fence catalog refresh ordering

* style(mcp): sort agent loader imports

* fix(mcp): cancel stale connection creation

* fix(mcp): fence catalog coordination

* fix(mcp): close catalog race windows

* fix(mcp): harden cross-pod catalog fencing

* fix(mcp): close catalog lifecycle edges

* style(mcp): sort assistant imports

* fix(mcp): reject stale recovery authority

* fix(mcp): restore static catalog on every startup

* fix(mcp): order app catalog publications

* style(mcp): sort catalog revision imports

* fix(mcp): separate catalog allocation and commit fences

---------

Co-authored-by: Pascal Garber <pascal@artandcode.studio>
This commit is contained in:
Danny Avila 2026-08-08 13:50:21 -04:00 committed by GitHub
parent ef38f362ec
commit 1bccc2bc18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 8046 additions and 831 deletions

View file

@ -177,14 +177,24 @@ const deleteUserMcpServers = async (userId) => {
const allServersToDelete = [...aclOwnedServers, ...legacyServers];
const mcpManager = getMCPManager();
if (mcpManager) {
await Promise.all(
allServersToDelete.map(async (s) => {
await mcpManager.disconnectUserConnection(userId, s.serverName);
await Promise.allSettled(
allServersToDelete.map(async (s) => {
try {
await invalidateCachedTools({ userId, serverName: s.serverName });
}),
);
}
} catch (error) {
logger.warn(
`[deleteUserMcpServers] Failed to invalidate tools for ${s.serverName}:`,
error,
);
} finally {
try {
await mcpManager?.disconnectUserConnection(userId, s.serverName);
} catch (error) {
logger.warn(`[deleteUserMcpServers] Failed to disconnect ${s.serverName}:`, error);
}
}
}),
);
await AclEntry.deleteMany({
resourceType: ResourceType.MCPSERVER,
@ -295,21 +305,37 @@ const updateUserPluginsController = async (req, res) => {
if (pluginKey.startsWith(Constants.mcp_prefix)) {
try {
const mcpManager = getMCPManager();
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
if (mcpManager) {
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
logger.info(
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
);
await mcpManager.disconnectUserConnection(user.id, serverName);
}
let invalidationError;
try {
await invalidateCachedTools({ userId: user.id, serverName });
} catch (error) {
invalidationError = error;
}
try {
await mcpManager?.disconnectUserConnection(user.id, serverName);
} catch (error) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
error,
);
}
if (invalidationError) {
throw invalidationError;
}
} catch (disconnectError) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
`[updateUserPluginsController] Error fencing MCP connection for user ${user.id} after plugin auth update:`,
disconnectError,
);
// Do not fail the request for this, but log it.
// A credential mutation is not safely published until the shared generation fence moves.
throw disconnectError;
}
}
return res.status(status).send();

View file

@ -174,6 +174,38 @@ beforeEach(() => {
});
describe('updateUserPluginsController MCP OAuth cleanup', () => {
it('invalidates the shared tool generation even when local disconnect fails', async () => {
const { mcpManager } = setupMCPMocks();
mcpManager.disconnectUserConnection.mockRejectedValue(new Error('local dispose failed'));
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
userId: 'user-1',
serverName: 'test-server',
});
expect(mockInvalidateCachedTools.mock.invocationCallOrder[0]).toBeLessThan(
mcpManager.disconnectUserConnection.mock.invocationCallOrder[0],
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('fails the credential update response when the shared generation fence cannot move', async () => {
const { mcpManager } = setupMCPMocks();
const fenceError = new Error('Redis unavailable');
mockInvalidateCachedTools.mockRejectedValue(fenceError);
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
const res = createResponse();
await updateUserPluginsController(createRequest(), res);
expect(mcpManager.disconnectUserConnection).toHaveBeenCalledWith('user-1', 'test-server');
expect(res.status).toHaveBeenCalledWith(500);
expect(logger.error).toHaveBeenCalledWith('[updateUserPluginsController]', fenceError);
});
it('clears stored OAuth token state when client metadata is missing', async () => {
const { flowManager, mcpManager } = setupMCPMocks();
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);

View file

@ -130,6 +130,42 @@ describe('deleteUserMcpServers', () => {
});
});
test('should delete owned servers when cache invalidation fails', async () => {
const userId = new mongoose.Types.ObjectId();
const server = await MCPServer.create({
serverName: 'cache-failure-server',
config: { title: 'Cache Failure Server' },
author: userId,
});
await permissionService.grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: userId,
});
const disconnectUserConnection = jest.fn().mockResolvedValue(undefined);
mockGetMCPManager.mockReturnValue({ disconnectUserConnection });
mockInvalidateCachedTools.mockRejectedValueOnce(new Error('Redis unavailable'));
await deleteUserMcpServers(userId.toString());
expect(disconnectUserConnection).toHaveBeenCalledWith(
userId.toString(),
'cache-failure-server',
);
expect(await MCPServer.findById(server._id)).toBeNull();
await expect(
AclEntry.countDocuments({
resourceType: ResourceType.MCPSERVER,
resourceId: server._id,
}),
).resolves.toBe(0);
});
test('should preserve multi-owned MCP servers', async () => {
const deletingUserId = new mongoose.Types.ObjectId();
const otherOwnerId = new mongoose.Types.ObjectId();
@ -263,6 +299,10 @@ describe('deleteUserMcpServers', () => {
await deleteUserMcpServers(userId.toString());
expect(await MCPServer.findById(server._id)).toBeNull();
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
userId: userId.toString(),
serverName: 'no-manager-server',
});
});
test('should delete legacy MCP servers that have author but no ACL entries', async () => {

View file

@ -24,11 +24,16 @@ jest.mock('~/server/services/GraphApiService', () => ({
const mockRegistryInstance = {
getServerConfig: jest.fn(),
inspectServerUpdate: jest.fn(),
commitServerUpdate: jest.fn(),
updateServer: jest.fn(),
removeServer: jest.fn(),
};
const mockMcpManager = { disconnectUserConnection: jest.fn() };
jest.mock('~/config', () => ({
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
getMCPManager: jest.fn(),
getMCPManager: jest.fn(() => mockMcpManager),
getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
}));
@ -41,10 +46,17 @@ jest.mock('~/server/services/MCP', () => ({
jest.mock('~/server/services/Config', () => ({
cacheMCPServerTools: jest.fn(),
getMCPToolsCacheGeneration: jest.fn().mockResolvedValue('test-generation'),
getMCPServerTools: jest.fn(),
invalidateCachedTools: jest.fn(),
}));
const { getMCPServersList, getMCPServerById } = require('~/server/controllers/mcp');
const {
getMCPServersList,
getMCPServerById,
updateMCPServerController,
deleteMCPServerController,
} = require('~/server/controllers/mcp');
const { grantPermission } = require('~/server/services/PermissionService');
const { seedDefaultRoles } = require('~/models');
@ -108,6 +120,16 @@ beforeEach(async () => {
await User.deleteMany({});
mockResolveAllMcpConfigs.mockReset();
mockRegistryInstance.getServerConfig.mockReset();
mockRegistryInstance.inspectServerUpdate.mockReset();
mockRegistryInstance.commitServerUpdate.mockReset();
mockRegistryInstance.updateServer.mockReset();
mockRegistryInstance.removeServer.mockReset();
mockMcpManager.disconnectUserConnection.mockReset().mockResolvedValue(undefined);
const cacheService = require('~/server/services/Config');
cacheService.invalidateCachedTools.mockReset().mockResolvedValue(undefined);
cacheService.getMCPServerTools.mockReset().mockResolvedValue({ retained: {} });
cacheService.getMCPToolsCacheGeneration.mockReset().mockResolvedValue('restored-generation');
cacheService.cacheMCPServerTools.mockReset().mockResolvedValue(undefined);
existsSpy = jest.spyOn(SystemGrant, 'exists');
});
@ -256,3 +278,212 @@ describe('getMCPServerById', () => {
expect(payload.oauth.authorization_url).toBeUndefined();
});
});
describe('DB-backed server mutation fencing', () => {
const updatedConfig = {
type: 'streamable-http',
url: 'https://updated.example.com/mcp',
source: 'user',
};
it('inspects, fences, commits, fences cross-replica creations, and disconnects', async () => {
const user = await createUser();
mockRegistryInstance.getServerConfig.mockResolvedValue(
createDbConfig(new mongoose.Types.ObjectId()),
);
mockRegistryInstance.inspectServerUpdate.mockResolvedValue(updatedConfig);
mockRegistryInstance.commitServerUpdate.mockResolvedValue(updatedConfig);
const res = createRes();
await updateMCPServerController(
{ user, params: { serverName: 'github' }, body: { config: updatedConfig } },
res,
);
const { invalidateCachedTools } = require('~/server/services/Config');
expect(invalidateCachedTools).toHaveBeenCalledWith({ userId: user.id, serverName: 'github' });
expect(invalidateCachedTools).toHaveBeenCalledTimes(2);
expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(user.id, 'github');
expect(mockRegistryInstance.inspectServerUpdate.mock.invocationCallOrder[0]).toBeLessThan(
invalidateCachedTools.mock.invocationCallOrder[0],
);
expect(invalidateCachedTools.mock.invocationCallOrder[0]).toBeLessThan(
mockRegistryInstance.commitServerUpdate.mock.invocationCallOrder[0],
);
expect(mockRegistryInstance.commitServerUpdate.mock.invocationCallOrder[0]).toBeLessThan(
invalidateCachedTools.mock.invocationCallOrder[1],
);
expect(invalidateCachedTools.mock.invocationCallOrder[1]).toBeLessThan(
mockMcpManager.disconnectUserConnection.mock.invocationCallOrder[0],
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('does not fence the valid catalog when update inspection or persistence fails', async () => {
const user = await createUser();
const updateError = new Error('inspection failed');
mockRegistryInstance.getServerConfig.mockResolvedValue(
createDbConfig(new mongoose.Types.ObjectId()),
);
mockRegistryInstance.inspectServerUpdate.mockRejectedValue(updateError);
const res = createRes();
await updateMCPServerController(
{ user, params: { serverName: 'github' }, body: { config: updatedConfig } },
res,
);
expect(require('~/server/services/Config').invalidateCachedTools).not.toHaveBeenCalled();
expect(mockRegistryInstance.commitServerUpdate).not.toHaveBeenCalled();
expect(mockMcpManager.disconnectUserConnection).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
});
it('does not commit an inspected update when the distributed fence fails', async () => {
const user = await createUser();
mockRegistryInstance.getServerConfig.mockResolvedValue(
createDbConfig(new mongoose.Types.ObjectId()),
);
mockRegistryInstance.inspectServerUpdate.mockResolvedValue(updatedConfig);
require('~/server/services/Config').invalidateCachedTools.mockRejectedValue(
new Error('Redis unavailable'),
);
const res = createRes();
await updateMCPServerController(
{ user, params: { serverName: 'github' }, body: { config: updatedConfig } },
res,
);
expect(mockMcpManager.disconnectUserConnection).not.toHaveBeenCalled();
expect(mockRegistryInstance.commitServerUpdate).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
});
it('restores the retained catalog when update persistence fails after fencing', async () => {
const user = await createUser();
const existingConfig = createDbConfig(new mongoose.Types.ObjectId());
const retainedTools = { retained: { function: { name: 'retained' } } };
mockRegistryInstance.getServerConfig.mockResolvedValue(existingConfig);
mockRegistryInstance.inspectServerUpdate.mockResolvedValue(updatedConfig);
mockRegistryInstance.commitServerUpdate.mockRejectedValue(new Error('database unavailable'));
require('~/server/services/Config').getMCPServerTools.mockResolvedValue(retainedTools);
const res = createRes();
await updateMCPServerController(
{ user, params: { serverName: 'github' }, body: { config: updatedConfig } },
res,
);
expect(require('~/server/services/Config').cacheMCPServerTools).toHaveBeenCalledWith({
userId: user.id,
serverName: 'github',
serverConfig: existingConfig,
serverTools: retainedTools,
publicationGeneration: 'restored-generation',
});
expect(mockMcpManager.disconnectUserConnection).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
});
it('continues an update when only local disconnect cleanup fails', async () => {
const user = await createUser();
mockRegistryInstance.getServerConfig.mockResolvedValue(
createDbConfig(new mongoose.Types.ObjectId()),
);
mockMcpManager.disconnectUserConnection.mockRejectedValue(new Error('dispose failed'));
mockRegistryInstance.inspectServerUpdate.mockResolvedValue(updatedConfig);
mockRegistryInstance.commitServerUpdate.mockResolvedValue(updatedConfig);
const res = createRes();
await updateMCPServerController(
{ user, params: { serverName: 'github' }, body: { config: updatedConfig } },
res,
);
expect(mockRegistryInstance.commitServerUpdate).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it('retries a transient post-commit fence failure before returning success', async () => {
const user = await createUser();
mockRegistryInstance.getServerConfig.mockResolvedValue(
createDbConfig(new mongoose.Types.ObjectId()),
);
mockRegistryInstance.inspectServerUpdate.mockResolvedValue(updatedConfig);
mockRegistryInstance.commitServerUpdate.mockResolvedValue(updatedConfig);
require('~/server/services/Config')
.invalidateCachedTools.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Redis MOVED'))
.mockResolvedValueOnce(undefined);
const res = createRes();
await updateMCPServerController(
{ user, params: { serverName: 'github' }, body: { config: updatedConfig } },
res,
);
expect(require('~/server/services/Config').invalidateCachedTools).toHaveBeenCalledTimes(3);
expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(user.id, 'github');
expect(res.status).toHaveBeenCalledWith(200);
});
it('fences before deletion and fences cross-replica creations before disconnecting', async () => {
const user = await createUser();
mockRegistryInstance.removeServer.mockResolvedValue(undefined);
const res = createRes();
await deleteMCPServerController({ user, params: { serverName: 'github' } }, res);
const { invalidateCachedTools } = require('~/server/services/Config');
expect(invalidateCachedTools).toHaveBeenCalledWith({ userId: user.id, serverName: 'github' });
expect(invalidateCachedTools).toHaveBeenCalledTimes(2);
expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(user.id, 'github');
expect(invalidateCachedTools.mock.invocationCallOrder[0]).toBeLessThan(
mockRegistryInstance.removeServer.mock.invocationCallOrder[0],
);
expect(mockRegistryInstance.removeServer.mock.invocationCallOrder[0]).toBeLessThan(
invalidateCachedTools.mock.invocationCallOrder[1],
);
expect(invalidateCachedTools.mock.invocationCallOrder[1]).toBeLessThan(
mockMcpManager.disconnectUserConnection.mock.invocationCallOrder[0],
);
expect(res.status).toHaveBeenCalledWith(200);
});
it('does not delete the registry entry when the distributed fence fails', async () => {
const user = await createUser();
require('~/server/services/Config').invalidateCachedTools.mockRejectedValue(
new Error('Redis unavailable'),
);
const res = createRes();
await deleteMCPServerController({ user, params: { serverName: 'github' } }, res);
expect(mockMcpManager.disconnectUserConnection).not.toHaveBeenCalled();
expect(mockRegistryInstance.removeServer).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
});
it('restores the retained catalog when deletion persistence fails after fencing', async () => {
const user = await createUser();
const existingConfig = createDbConfig(new mongoose.Types.ObjectId());
const retainedTools = { retained: { function: { name: 'retained' } } };
mockRegistryInstance.getServerConfig.mockResolvedValue(existingConfig);
mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
require('~/server/services/Config').getMCPServerTools.mockResolvedValue(retainedTools);
const res = createRes();
await deleteMCPServerController({ user, params: { serverName: 'github' } }, res);
expect(require('~/server/services/Config').cacheMCPServerTools).toHaveBeenCalledWith({
userId: user.id,
serverName: 'github',
serverConfig: existingConfig,
serverTools: retainedTools,
publicationGeneration: 'restored-generation',
});
expect(mockMcpManager.disconnectUserConnection).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
});
});

View file

@ -7,8 +7,7 @@ const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { healMcpToolNames } = require('~/server/services/MCP');
const { getCachedTools } = require('~/server/services/Config');
const { healMcpToolNames, getAssistantToolDefinitions } = require('~/server/services/MCP');
const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
/**
@ -31,7 +30,7 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = (await getCachedTools()) ?? {};
const toolDefinitions = await getAssistantToolDefinitions({ req, tools });
const healedTools = await healMcpToolNames({ req, tools, toolDefinitions });
assistantData.tools = healedTools
@ -146,7 +145,7 @@ const patchAssistant = async (req, res) => {
...updateData
} = req.body;
const toolDefinitions = (await getCachedTools()) ?? {};
const toolDefinitions = await getAssistantToolDefinitions({ req, tools: updateData.tools });
const healedTools = await healMcpToolNames({ req, tools: updateData.tools, toolDefinitions });
updateData.tools = healedTools

View file

@ -2,8 +2,7 @@ const { logger } = require('@librechat/data-schemas');
const { ToolCallTypes } = require('librechat-data-provider');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { healMcpToolNames } = require('~/server/services/MCP');
const { getCachedTools } = require('~/server/services/Config');
const { healMcpToolNames, getAssistantToolDefinitions } = require('~/server/services/MCP');
const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
const { updateAssistantDoc } = require('~/models');
const { getOpenAIClient } = require('./helpers');
@ -29,7 +28,7 @@ const createAssistant = async (req, res) => {
delete assistantData.conversation_starters;
delete assistantData.append_current_datetime;
const toolDefinitions = (await getCachedTools()) ?? {};
const toolDefinitions = await getAssistantToolDefinitions({ req, tools });
const healedTools = await healMcpToolNames({ req, tools, toolDefinitions });
assistantData.tools = healedTools
@ -135,7 +134,7 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
}
let hasFileSearch = false;
const toolDefinitions = (await getCachedTools()) ?? {};
const toolDefinitions = await getAssistantToolDefinitions({ req, tools: updateData.tools });
const healedTools = await healMcpToolNames({ req, tools: updateData.tools, toolDefinitions });
for (const tool of healedTools) {
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on

View file

@ -35,7 +35,12 @@ const {
resolveMcpConfigNames,
resolveAllMcpConfigs,
} = require('~/server/services/MCP');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const {
cacheMCPServerTools,
getMCPServerTools,
getMCPToolsCacheGeneration,
invalidateCachedTools,
} = require('~/server/services/Config');
const { getResourcePermissionsMap } = require('~/server/services/PermissionService');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
@ -94,6 +99,68 @@ function handleMCPError(error, res) {
return null;
}
/** Disposes a stale local connection after its DB-backed config has changed. */
async function disconnectLocalMCPServer(userId, serverName) {
try {
await getMCPManager()?.disconnectUserConnection(userId, serverName);
} catch (error) {
logger.warn(
`[MCP Cache] Failed to disconnect the local connection for ${serverName} (user: ${userId}):`,
error,
);
}
}
const POST_COMMIT_FENCE_RETRY_DELAYS_MS = [0, 50, 200];
/** Retries the shared fence after persistence; config-bound connections remain a durable
* fallback if Redis stays unavailable, so an old connection cannot serve the new config. */
async function fenceCommittedMCPMutation({ userId, serverName }) {
let lastError;
for (const delay of POST_COMMIT_FENCE_RETRY_DELAYS_MS) {
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
try {
await invalidateCachedTools({ userId, serverName });
return;
} catch (error) {
lastError = error;
logger.warn(
`[MCP Cache] Failed to fence committed mutation for ${serverName} (user: ${userId}); retrying:`,
error,
);
}
}
throw lastError;
}
/**
* Republishes the pre-mutation catalog under the new fence when persistence
* fails. The retained connection will reacquire that generation on its next
* use; this snapshot keeps every replica authoritative in the meantime.
*/
async function restoreRetainedServerCatalog({ userId, serverName, serverConfig, serverTools }) {
if (serverTools == null) {
return;
}
try {
const publicationGeneration = await getMCPToolsCacheGeneration({ userId, serverName });
await cacheMCPServerTools({
userId,
serverName,
serverConfig,
serverTools,
publicationGeneration,
});
} catch (error) {
logger.error(
`[MCP Cache] Failed to restore the retained catalog for ${serverName} (user: ${userId}):`,
error,
);
}
}
/**
* Get all MCP tools available to the user.
*/
@ -150,8 +217,14 @@ const getMCPTools = async (req, res) => {
}
let serverTools;
let publicationGeneration;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
({ tools: serverTools, publicationGeneration } =
await mcpManager.getServerToolFunctionsSnapshot(
userId,
serverName,
mcpConfig[serverName],
));
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
@ -162,17 +235,16 @@ const getMCPTools = async (req, res) => {
}
serverToolsMap.set(serverName, serverTools);
if (Object.keys(serverTools).length > 0) {
// Cache asynchronously without blocking
cacheMCPServerTools({
userId,
serverName,
serverTools,
serverConfig: mcpConfig[serverName],
}).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
// Empty is an authoritative catalog too; re-cache it after TTL expiry to avoid polling.
cacheMCPServerTools({
userId,
serverName,
serverTools,
serverConfig: mcpConfig[serverName],
publicationGeneration,
}).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
// Process each configured server
@ -509,12 +581,30 @@ const updateMCPServerController = async (req, res) => {
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const parsedConfig = await getMCPServersRegistry().updateServer(
const registry = getMCPServersRegistry();
const parsedConfig = await registry.inspectServerUpdate(
serverName,
validation.data,
'DB',
userId,
);
const retainedTools = await getMCPServerTools(userId, serverName, existingConfig);
await invalidateCachedTools({ userId, serverName });
try {
await registry.commitServerUpdate(serverName, parsedConfig, 'DB', userId);
} catch (error) {
await restoreRetainedServerCatalog({
userId,
serverName,
serverConfig: existingConfig,
serverTools: retainedTools,
});
throw error;
}
/** Fence connections another replica could have created from the old DB
* config between the pre-commit fence and the committed update. */
await fenceCommittedMCPMutation({ userId, serverName });
await disconnectLocalMCPServer(userId, serverName);
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true }));
} catch (error) {
@ -535,7 +625,24 @@ const deleteMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
await getMCPServersRegistry().removeServer(serverName, 'DB', userId);
const registry = getMCPServersRegistry();
const existingConfig = await registry.getServerConfig(serverName, userId);
const retainedTools = await getMCPServerTools(userId, serverName, existingConfig);
await invalidateCachedTools({ userId, serverName });
try {
await registry.removeServer(serverName, 'DB', userId);
} catch (error) {
await restoreRetainedServerCatalog({
userId,
serverName,
serverConfig: existingConfig,
serverTools: retainedTools,
});
throw error;
}
/** Fence connections another replica could have created before deletion committed. */
await fenceCommittedMCPMutation({ userId, serverName });
await disconnectLocalMCPServer(userId, serverName);
res.status(200).json({ message: 'MCP server deleted successfully' });
} catch (error) {
logger.error('[deleteMCPServer]', error);

View file

@ -15,6 +15,7 @@ jest.mock('~/server/services/Config', () => ({
fileStrategy: 'local',
imageOutputType: 'PNG',
}),
mergeAppTools: jest.fn().mockResolvedValue(undefined),
setCachedTools: jest.fn(),
}));

View file

@ -5,6 +5,7 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
jest.mock('~/server/services/Config', () => ({
mergeAppTools: jest.fn().mockResolvedValue(undefined),
loadCustomConfig: jest.fn(() => Promise.resolve({})),
getAppConfig: jest.fn().mockResolvedValue({
paths: {

View file

@ -20,6 +20,8 @@ const mockRegistryInstance = {
getAllServerConfigs: jest.fn(),
ensureConfigServers: jest.fn().mockResolvedValue({}),
addServer: jest.fn(),
inspectServerUpdate: jest.fn(),
commitServerUpdate: jest.fn(),
updateServer: jest.fn(),
removeServer: jest.fn(),
getAllowedDomains: jest.fn().mockReturnValue(null),
@ -128,6 +130,9 @@ jest.mock('~/models', () => ({
jest.mock('~/server/services/Config', () => ({
setCachedTools: jest.fn(),
getCachedTools: jest.fn(),
cacheMCPServerTools: jest.fn(),
getMCPToolsCacheGeneration: jest.fn().mockResolvedValue('test-generation'),
invalidateCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
loadCustomConfig: jest.fn(),
getAppConfig: jest.fn().mockResolvedValue({ mcpConfig: {} }),
@ -272,8 +277,15 @@ describe('MCP Routes', () => {
*/
mockRegistryInstance.getServerConfig.mockReset().mockResolvedValue(undefined);
mockRegistryInstance.addServer.mockReset();
mockRegistryInstance.inspectServerUpdate.mockReset();
mockRegistryInstance.commitServerUpdate.mockReset();
mockRegistryInstance.updateServer.mockReset();
mockRegistryInstance.removeServer.mockReset();
const cacheService = require('~/server/services/Config');
cacheService.getMCPServerTools.mockReset().mockResolvedValue(null);
cacheService.getMCPToolsCacheGeneration.mockReset().mockResolvedValue('test-generation');
cacheService.cacheMCPServerTools.mockReset().mockResolvedValue(undefined);
cacheService.invalidateCachedTools.mockReset().mockResolvedValue(undefined);
});
describe('GET /:serverName/oauth/initiate', () => {
@ -843,7 +855,7 @@ describe('MCP Routes', () => {
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -905,7 +917,7 @@ describe('MCP Routes', () => {
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -958,9 +970,12 @@ describe('MCP Routes', () => {
mockRegistryInstance.getServerConfig.mockResolvedValue({});
mockResolveAllMcpConfigs.mockResolvedValueOnce({ 'test-server': mergedServerConfig });
const fetchOrderedToolsSnapshot = jest
.fn()
.mockResolvedValue({ tools: fetchedTools, complete: true });
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue(fetchedTools),
fetchOrderedToolsSnapshot,
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -976,6 +991,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(302);
expect(mockResolveAllMcpConfigs).toHaveBeenCalledWith('test-user-id');
expect(fetchOrderedToolsSnapshot).toHaveBeenCalledTimes(1);
expect(mockMcpManager.getUserConnection).toHaveBeenCalledWith(
expect.objectContaining({ serverConfig: mergedServerConfig }),
);
@ -1030,7 +1046,9 @@ describe('MCP Routes', () => {
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue(fetchedTools),
fetchToolsSnapshot: jest
.fn()
.mockResolvedValue({ tools: fetchedTools, complete: true }),
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -1093,7 +1111,9 @@ describe('MCP Routes', () => {
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue(fetchedTools),
fetchToolsSnapshot: jest
.fn()
.mockResolvedValue({ tools: fetchedTools, complete: true }),
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -1209,13 +1229,16 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([
{
name: 'test-tool',
description: 'A test tool',
inputSchema: { type: 'object' },
},
]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({
tools: [
{
name: 'test-tool',
description: 'A test tool',
inputSchema: { type: 'object' },
},
],
complete: true,
}),
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
@ -1310,7 +1333,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue({
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
@ -1385,7 +1408,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue({
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
@ -1443,7 +1466,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue({
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
@ -1497,7 +1520,7 @@ describe('MCP Routes', () => {
});
require('~/config').getMCPManager.mockReturnValue({
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
@ -1730,7 +1753,7 @@ describe('MCP Routes', () => {
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
};
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
@ -2389,10 +2412,13 @@ describe('MCP Routes', () => {
it('should successfully reinitialize server and cache tools', async () => {
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([
{ name: 'tool1', description: 'Test tool 1', inputSchema: { type: 'object' } },
{ name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } },
]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({
tools: [
{ name: 'tool1', description: 'Test tool 1', inputSchema: { type: 'object' } },
{ name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } },
],
complete: true,
}),
};
const mockMcpManager = {
@ -2435,11 +2461,18 @@ describe('MCP Routes', () => {
'test-user-id',
'test-server',
);
expect(require('~/server/services/Config').invalidateCachedTools).toHaveBeenCalledWith({
userId: 'test-user-id',
serverName: 'test-server',
});
expect(
require('~/server/services/Config').invalidateCachedTools.mock.invocationCallOrder[0],
).toBeLessThan(mockMcpManager.disconnectUserConnection.mock.invocationCallOrder[0]);
});
it('should handle server with custom user variables', async () => {
const mockUserConnection = {
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
};
const mockMcpManager = {
@ -2896,7 +2929,7 @@ describe('MCP Routes', () => {
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({ tools: [], complete: true }),
}),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -2949,10 +2982,12 @@ describe('MCP Routes', () => {
const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest
.fn()
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
fetchToolsSnapshot: jest.fn().mockResolvedValue({
tools: [{ name: 'test-tool', description: 'Test tool' }],
complete: true,
}),
}),
getToolPublicationGeneration: jest.fn().mockReturnValue('oauth-connection-generation'),
};
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -2967,6 +3002,60 @@ describe('MCP Routes', () => {
const basePath = getBasePath();
expect(response.headers.location).toContain(`${basePath}/oauth/success`);
expect(require('~/server/services/Config/mcp').updateMCPServerTools).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'test-user-id',
serverName: 'test-server',
tools: [{ name: 'test-tool', description: 'Test tool' }],
publicationGeneration: 'oauth-connection-generation',
}),
);
});
it('preserves cached tools when the post-OAuth snapshot is incomplete', async () => {
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
const mockTokens = {
access_token: 'edge-access-token',
refresh_token: 'edge-refresh-token',
};
const mockFlowManager = {
getFlowState: jest.fn(),
completeFlow: jest.fn(),
};
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.getFlowState.mockResolvedValue({
state: 'test-user-id:test-server',
serverName: 'test-server',
userId: 'test-user-id',
metadata: { serverUrl: 'https://example.com', oauth: {} },
clientInfo: {},
codeVerifier: 'test-verifier',
});
mockOAuthCompletion(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue();
mockRegistryInstance.getServerConfig.mockResolvedValue({});
require('~/config').getMCPManager.mockReturnValue({
getUserConnection: jest.fn().mockResolvedValue({
fetchToolsSnapshot: jest.fn().mockResolvedValue({
tools: [{ name: 'partial-tool', description: 'Only the first page' }],
complete: false,
}),
}),
getToolPublicationGeneration: jest.fn().mockReturnValue('oauth-connection-generation'),
});
const flowId = 'test-user-id:test-server';
const csrfToken = generateTestCsrfToken(flowId);
await request(app)
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.expect(302);
expect(require('~/server/services/Config/mcp').updateMCPServerTools).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'[MCP OAuth] Preserving cached tools for test-server because tools/list returned an incomplete snapshot',
);
});
});
@ -3064,6 +3153,68 @@ describe('MCP Routes', () => {
expect(mockResolveAllMcpConfigs).not.toHaveBeenCalled();
});
it('caches a live user snapshot with its connection-bound publication generation', async () => {
const { Constants } = require('librechat-data-provider');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const pluginKey = `search${Constants.mcp_delimiter}user-server`;
const serverTools = {
[pluginKey]: {
type: 'function',
function: {
name: pluginKey,
description: 'Search',
parameters: { type: 'object' },
},
},
};
const serverConfig = { type: 'sse', url: 'https://user.example.com/sse' };
mockResolveAllMcpConfigs.mockResolvedValueOnce({ 'user-server': serverConfig });
getMCPServerTools.mockResolvedValueOnce(null);
cacheMCPServerTools.mockResolvedValueOnce();
require('~/config').getMCPManager.mockReturnValue({
getServerToolFunctionsSnapshot: jest.fn().mockResolvedValue({
tools: serverTools,
publicationGeneration: 'connection-generation',
}),
});
const response = await request(app).get('/api/mcp/tools');
expect(response.status).toBe(200);
expect(cacheMCPServerTools).toHaveBeenCalledWith({
userId: 'test-user-id',
serverName: 'user-server',
serverTools,
serverConfig,
publicationGeneration: 'connection-generation',
});
});
it('re-caches an authoritative empty live snapshot after a cache miss', async () => {
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const serverConfig = { type: 'sse', url: 'https://empty.example.com/sse' };
mockResolveAllMcpConfigs.mockResolvedValueOnce({ 'empty-server': serverConfig });
getMCPServerTools.mockResolvedValueOnce(null);
cacheMCPServerTools.mockResolvedValueOnce();
const getServerToolFunctionsSnapshot = jest.fn().mockResolvedValue({
tools: {},
publicationGeneration: undefined,
});
require('~/config').getMCPManager.mockReturnValue({ getServerToolFunctionsSnapshot });
const response = await request(app).get('/api/mcp/tools');
expect(response.status).toBe(200);
expect(response.body.servers['empty-server'].tools).toEqual([]);
expect(cacheMCPServerTools).toHaveBeenCalledWith({
userId: 'test-user-id',
serverName: 'empty-server',
serverTools: {},
serverConfig,
publicationGeneration: undefined,
});
});
it('should continue returning MCP tools when one server cache lookup fails', async () => {
const { Constants } = require('librechat-data-provider');
const { logger } = require('@librechat/data-schemas');
@ -3095,9 +3246,12 @@ describe('MCP Routes', () => {
},
});
const mockGetServerToolFunctions = jest.fn().mockResolvedValue(null);
const mockGetServerToolFunctionsSnapshot = jest.fn().mockResolvedValue({
tools: null,
publicationGeneration: 'test-generation',
});
require('~/config').getMCPManager.mockReturnValue({
getServerToolFunctions: mockGetServerToolFunctions,
getServerToolFunctionsSnapshot: mockGetServerToolFunctionsSnapshot,
});
const response = await request(app).get('/api/mcp/tools');
@ -3107,7 +3261,14 @@ describe('MCP Routes', () => {
'[getMCPTools] Error fetching cached tools for bad-server:',
expect.any(Error),
);
expect(mockGetServerToolFunctions).toHaveBeenCalledWith('test-user-id', 'bad-server');
expect(mockGetServerToolFunctionsSnapshot).toHaveBeenCalledWith(
'test-user-id',
'bad-server',
{
type: 'sse',
url: 'https://bad.example.com/sse',
},
);
expect(response.body.servers['good-server']).toMatchObject({
name: 'good-server',
icon: '/icons/good.svg',
@ -3142,9 +3303,12 @@ describe('MCP Routes', () => {
getMCPServerTools.mockRejectedValue(new Error('cache unavailable'));
const mockGetServerToolFunctions = jest.fn().mockResolvedValue(null);
const mockGetServerToolFunctionsSnapshot = jest.fn().mockResolvedValue({
tools: null,
publicationGeneration: 'test-generation',
});
require('~/config').getMCPManager.mockReturnValue({
getServerToolFunctions: mockGetServerToolFunctions,
getServerToolFunctionsSnapshot: mockGetServerToolFunctionsSnapshot,
});
const response = await request(app).get('/api/mcp/tools');
@ -3159,7 +3323,7 @@ describe('MCP Routes', () => {
tools: [],
});
expect(logger.error).toHaveBeenCalledTimes(2);
expect(mockGetServerToolFunctions).toHaveBeenCalledTimes(2);
expect(mockGetServerToolFunctionsSnapshot).toHaveBeenCalledTimes(2);
});
});
@ -3567,7 +3731,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
expect(mockRegistryInstance.inspectServerUpdate).not.toHaveBeenCalled();
});
it('allows PATCH without CONFIGURE_OBO when OBO is unchanged', async () => {
@ -3589,10 +3753,11 @@ describe('MCP Routes', () => {
...oboConfig,
obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' },
});
mockRegistryInstance.updateServer.mockResolvedValue({
mockRegistryInstance.inspectServerUpdate.mockResolvedValue({
...oboConfig,
title: 'Renamed OBO Server',
});
mockRegistryInstance.commitServerUpdate.mockResolvedValue(undefined);
const response = await request(app)
.patch('/api/mcp/servers/obo-server')
@ -3605,7 +3770,7 @@ describe('MCP Routes', () => {
});
expect(response.status).toBe(200);
expect(mockRegistryInstance.updateServer).toHaveBeenCalled();
expect(mockRegistryInstance.commitServerUpdate).toHaveBeenCalled();
});
it('rejects PATCH that removes OBO from an existing OBO server without CONFIGURE_OBO', async () => {
@ -3639,7 +3804,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
expect(mockRegistryInstance.inspectServerUpdate).not.toHaveBeenCalled();
});
it('rejects PATCH that redirects the URL of an existing OBO server without CONFIGURE_OBO', async () => {
@ -3676,7 +3841,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(403);
expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/);
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
expect(mockRegistryInstance.inspectServerUpdate).not.toHaveBeenCalled();
});
});
@ -3772,7 +3937,11 @@ describe('MCP Routes', () => {
description: 'Updated description',
};
mockRegistryInstance.updateServer.mockResolvedValue({ ...updatedConfig, source: 'user' });
mockRegistryInstance.inspectServerUpdate.mockResolvedValue({
...updatedConfig,
source: 'user',
});
mockRegistryInstance.commitServerUpdate.mockResolvedValue(undefined);
const response = await request(app)
.patch('/api/mcp/servers/test-server')
@ -3782,7 +3951,7 @@ describe('MCP Routes', () => {
expect(response.body.type).toBe('sse');
expect(response.body.url).toBe('https://updated-mcp-server.example.com/sse');
expect(response.body.title).toBe('Updated Server');
expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
expect(mockRegistryInstance.inspectServerUpdate).toHaveBeenCalledWith(
'test-server',
expect.objectContaining({
type: 'sse',
@ -3800,13 +3969,14 @@ describe('MCP Routes', () => {
title: 'Updated Server',
};
mockRegistryInstance.updateServer.mockResolvedValue({
mockRegistryInstance.inspectServerUpdate.mockResolvedValue({
...validConfig,
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'preserved-admin-key' },
oauth: { client_id: 'cid', client_secret: 'preserved-oauth-secret' },
headers: { Authorization: 'Bearer internal-token' },
env: { DATABASE_URL: 'postgres://admin:pass@localhost/db' },
});
mockRegistryInstance.commitServerUpdate.mockResolvedValue(undefined);
const response = await request(app)
.patch('/api/mcp/servers/test-server')
@ -3832,7 +4002,7 @@ describe('MCP Routes', () => {
statusCode: 400,
},
);
mockRegistryInstance.updateServer.mockRejectedValue(error);
mockRegistryInstance.inspectServerUpdate.mockRejectedValue(error);
const response = await request(app)
.patch('/api/mcp/servers/test-server')
@ -3884,7 +4054,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
expect(mockRegistryInstance.inspectServerUpdate).not.toHaveBeenCalled();
});
it('should reject streamable-http URL containing env variable references', async () => {
@ -3899,7 +4069,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
expect(mockRegistryInstance.inspectServerUpdate).not.toHaveBeenCalled();
});
it('should reject websocket URL containing env variable references', async () => {
@ -3914,7 +4084,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
expect(mockRegistryInstance.inspectServerUpdate).not.toHaveBeenCalled();
});
it('should return 500 when registry throws error', async () => {
@ -3924,7 +4094,7 @@ describe('MCP Routes', () => {
title: 'Test Server',
};
mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
mockRegistryInstance.inspectServerUpdate.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.patch('/api/mcp/servers/test-server')

View file

@ -46,6 +46,7 @@ const {
} = require('~/server/services/MCP');
const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { invalidateCachedTools } = require('~/server/services/Config');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { getLogStores } = require('~/cache');
@ -542,13 +543,24 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
const oauthReconnectionManager = getOAuthReconnectionManager();
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
const tools = await userConnection.fetchTools();
await updateMCPServerTools({
userId: flowState.userId,
serverName,
tools,
serverConfig,
});
const snapshot =
typeof userConnection.fetchOrderedToolsSnapshot === 'function'
? await userConnection.fetchOrderedToolsSnapshot()
: await userConnection.fetchToolsSnapshot();
if (snapshot.complete) {
const publicationGeneration = mcpManager.getToolPublicationGeneration?.(userConnection);
await updateMCPServerTools({
userId: flowState.userId,
serverName,
tools: snapshot.tools,
serverConfig,
publicationGeneration,
});
} else {
logger.warn(
`[MCP OAuth] Preserving cached tools for ${serverName} because tools/list returned an incomplete snapshot`,
);
}
} else {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
@ -791,7 +803,11 @@ router.post(
});
}
await mcpManager.disconnectUserConnection(user.id, serverName);
try {
await invalidateCachedTools({ userId: user.id, serverName });
} finally {
await mcpManager.disconnectUserConnection(user.id, serverName);
}
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);

View file

@ -0,0 +1,341 @@
const { CacheKeys } = require('librechat-data-provider');
const calculateSlot = require('cluster-key-slot');
const mockRedisClient = {
set: jest.fn(),
eval: jest.fn(),
};
const mockKeyvRedisClient = {
eval: jest.fn(),
};
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
cacheConfig: { FORCED_IN_MEMORY_CACHE_NAMESPACES: [] },
mcpConfig: { USER_CONNECTION_IDLE_TIMEOUT: 15 * 60 * 1000 },
ioredisClient: mockRedisClient,
keyvRedisClient: mockKeyvRedisClient,
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { warn: jest.fn() },
}));
jest.mock('~/cache/getLogStores', () => jest.fn());
const getLogStores = require('~/cache/getLogStores');
const mockCache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() };
getLogStores.mockReturnValue(mockCache);
const {
getCachedTools,
updateCachedGlobalTools,
getMCPToolsCacheGeneration,
setCachedTools,
setCachedToolsIfCurrent,
runWithGlobalCacheLock,
invalidateCachedTools,
setCachedToolsWithinGlobalLock,
getNextAppToolsPublicationRevision,
setCachedAppServerTools,
} = require('../getCachedTools');
describe('global tool cache write lock', () => {
afterEach(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
mockRedisClient.set.mockResolvedValue('OK');
mockRedisClient.eval.mockResolvedValue(1);
mockKeyvRedisClient.eval.mockResolvedValue(1);
mockCache.set.mockResolvedValue(true);
mockCache.delete.mockResolvedValue(true);
});
it('acquires and safely releases the Redis lock around an aggregate update', async () => {
const operation = jest.fn().mockResolvedValue('updated');
await expect(runWithGlobalCacheLock(operation)).resolves.toBe('updated');
expect(mockRedisClient.set).toHaveBeenCalledWith(
`${CacheKeys.TOOL_CACHE}:tools:global:write-lock`,
expect.any(String),
'PX',
30_000,
'NX',
);
const token = mockRedisClient.set.mock.calls[0][1];
expect(mockRedisClient.eval).toHaveBeenCalledWith(
expect.stringContaining("redis.call('GET'"),
1,
`${CacheKeys.TOOL_CACHE}:tools:global:write-lock`,
token,
);
const fenceKey = mockKeyvRedisClient.eval.mock.calls[0][1].keys[0];
expect(calculateSlot(fenceKey)).toBe(calculateSlot(`${CacheKeys.TOOL_CACHE}:tools:global`));
});
it('releases the Redis lock when the aggregate update fails', async () => {
const operation = jest.fn().mockRejectedValue(new Error('cache read failed'));
await expect(runWithGlobalCacheLock(operation)).rejects.toThrow('cache read failed');
expect(mockRedisClient.eval).toHaveBeenCalledTimes(1);
});
it('serializes direct global writes and invalidation', async () => {
await setCachedTools({ builtin: {} });
await invalidateCachedTools({ invalidateGlobal: true });
expect(mockRedisClient.set).toHaveBeenCalledTimes(2);
expect(mockRedisClient.eval).toHaveBeenCalledTimes(2);
});
it('does not reacquire the lock for a write already inside an aggregate update', async () => {
await runWithGlobalCacheLock(() => setCachedToolsWithinGlobalLock({ mcp: {} }));
expect(mockRedisClient.set).toHaveBeenCalledTimes(1);
expect(mockRedisClient.eval).toHaveBeenCalledTimes(1);
expect(mockKeyvRedisClient.eval).toHaveBeenCalledWith(
expect.stringContaining("redis.call('GET', KEYS[1])"),
expect.objectContaining({
keys: [
`tools:global:write-fence:{${CacheKeys.TOOL_CACHE}:tools:global}`,
`${CacheKeys.TOOL_CACHE}:tools:global`,
],
}),
);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('atomically replaces a legacy global catalog while holding its Redis fence', async () => {
mockCache.get.mockResolvedValue({ old_mcp_server: {}, builtin: {} });
await updateCachedGlobalTools(() => ({ builtin: {} }));
expect(mockRedisClient.set).toHaveBeenCalledTimes(1);
expect(mockKeyvRedisClient.eval).toHaveBeenCalledTimes(3);
expect(mockKeyvRedisClient.eval.mock.calls).toEqual(
expect.arrayContaining([
[
expect.stringContaining("redis.call('PSETEX', KEYS[2]"),
expect.objectContaining({
keys: [
`tools:global:write-fence:{${CacheKeys.TOOL_CACHE}:tools:global}`,
`${CacheKeys.TOOL_CACHE}:tools:global`,
],
arguments: [
expect.any(String),
JSON.stringify({ builtin: {} }),
expect.any(String),
expect.any(String),
],
}),
],
]),
);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('rejects a Redis-backed global write made without distributed lock ownership', async () => {
await expect(setCachedToolsWithinGlobalLock({ unsafe: {} })).rejects.toThrow(
'Global tool cache write requires lock ownership',
);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockKeyvRedisClient.eval).not.toHaveBeenCalled();
});
it('rejects a delayed global write after its distributed lease is lost', async () => {
mockKeyvRedisClient.eval.mockResolvedValueOnce(1).mockResolvedValueOnce(0).mockResolvedValue(1);
await expect(
runWithGlobalCacheLock(() => setCachedToolsWithinGlobalLock({ stale: {} })),
).rejects.toThrow('Global tool cache lock ownership was lost before write');
expect(mockCache.set).not.toHaveBeenCalled();
});
it('rejects a delayed ownership claim after a newer owner has fenced the slot', async () => {
const operation = jest.fn();
mockKeyvRedisClient.eval.mockResolvedValueOnce(0);
await expect(runWithGlobalCacheLock(operation)).rejects.toThrow(
'Tool cache lock expired or was superseded before ownership could be fenced',
);
expect(operation).not.toHaveBeenCalled();
const [claimScript, claimOptions] = mockKeyvRedisClient.eval.mock.calls[0];
expect(claimScript).toContain('current ~= ARGV[1]');
expect(claimScript).toContain("redis.call('TIME')");
expect(claimOptions.arguments).toEqual([
mockRedisClient.set.mock.calls[0][1],
expect.any(String),
'1000',
]);
});
it('atomically checks the generation and writes a generation-guarded user catalog', async () => {
await expect(
setCachedToolsIfCurrent(
{ current: {} },
{
userId: 'user-1',
serverName: 'server-1',
configGeneration: 'config-current',
publicationGeneration: 'generation-current',
},
),
).resolves.toBe(true);
expect(mockRedisClient.set).toHaveBeenCalledWith(
`${CacheKeys.TOOL_CACHE}:tools:mcp-write-lock:user-1:server-1`,
expect.any(String),
'PX',
30_000,
'NX',
);
expect(mockRedisClient.eval).toHaveBeenCalledTimes(1);
expect(mockKeyvRedisClient.eval).toHaveBeenCalledWith(
expect.stringContaining("redis.call('PSETEX', KEYS[2]"),
expect.objectContaining({
keys: [
`${CacheKeys.TOOL_CACHE}:tools:metadata:mcp:user-generation:{user-1:server-1}`,
`${CacheKeys.TOOL_CACHE}:tools:mcp:user:{user-1:server-1}:config-current`,
],
arguments: [
'generation-current',
expect.any(String),
expect.any(String),
expect.stringContaining('"publicationGeneration":"generation-current"'),
expect.any(String),
expect.any(String),
],
}),
);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('does not write tools when the atomic generation check observes a replacement', async () => {
mockKeyvRedisClient.eval.mockResolvedValue(0);
await expect(
setCachedToolsIfCurrent(
{ stale: {} },
{
userId: 'user-1',
serverName: 'server-1',
configGeneration: 'config-old',
publicationGeneration: 'generation-old',
},
),
).resolves.toBe(false);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('orders app snapshots atomically in the app catalog Redis slot', async () => {
mockCache.get.mockResolvedValue(null);
mockKeyvRedisClient.eval
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(0);
const older = await getNextAppToolsPublicationRevision('server-1', 'config-current');
const newer = await getNextAppToolsPublicationRevision('server-1', 'config-current');
await expect(
setCachedAppServerTools('server-1', 'config-current', { current: {} }, newer),
).resolves.toBe(true);
await expect(
setCachedAppServerTools('server-1', 'config-current', { stale: {} }, older),
).resolves.toBe(false);
const [reserveScript, reserveOptions] = mockKeyvRedisClient.eval.mock.calls[0];
const [writeScript, writeOptions] = mockKeyvRedisClient.eval.mock.calls[2];
expect(reserveScript).toContain("redis.call('INCR', KEYS[1])");
expect(writeScript).toContain('tonumber(current) > tonumber(ARGV[1])');
expect(writeScript).toContain("currentEntry['value']['publicationRevision']");
expect(calculateSlot(reserveOptions.keys[0])).toBe(calculateSlot(writeOptions.keys[1]));
expect(calculateSlot(writeOptions.keys[0])).toBe(calculateSlot(writeOptions.keys[1]));
expect(writeOptions.keys[0]).toContain('app-committed-revision');
expect(writeOptions.keys[0]).not.toBe(reserveOptions.keys[0]);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('serializes legacy user catalog migration with Redis-backed writers', async () => {
const legacy = { legacy: {} };
mockCache.get
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(legacy)
.mockResolvedValueOnce('generation-current')
.mockResolvedValueOnce('generation-current');
await expect(
getCachedTools({
userId: 'user-1',
serverName: 'server-1',
configGeneration: 'config-current',
}),
).resolves.toBe(legacy);
expect(mockRedisClient.set).toHaveBeenCalledWith(
`${CacheKeys.TOOL_CACHE}:tools:mcp-write-lock:user-1:server-1`,
expect.any(String),
'PX',
30_000,
'NX',
);
expect(mockKeyvRedisClient.eval).toHaveBeenCalledWith(
expect.stringContaining("redis.call('EXISTS', KEYS[2])"),
expect.objectContaining({
keys: [
`tools:mcp:write-fence:{user-1:server-1}`,
`${CacheKeys.TOOL_CACHE}:tools:metadata:mcp:user-legacy-fence:{user-1:server-1}`,
`${CacheKeys.TOOL_CACHE}:tools:metadata:mcp:user-generation:{user-1:server-1}`,
`${CacheKeys.TOOL_CACHE}:tools:mcp:user:{user-1:server-1}:config-current`,
],
}),
);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('rejects first-generation creation after its distributed lock ownership is lost', async () => {
mockCache.get.mockResolvedValue(null);
mockKeyvRedisClient.eval
.mockResolvedValueOnce(1)
.mockResolvedValueOnce(-1)
.mockResolvedValue(1);
await expect(
getMCPToolsCacheGeneration({ userId: 'user-1', serverName: 'server-1' }),
).rejects.toThrow('Tool cache lock ownership was lost before generation creation');
const createCall = mockKeyvRedisClient.eval.mock.calls.find(([script]) =>
script.includes("redis.call('EXISTS', KEYS[2])"),
);
expect(calculateSlot(createCall[1].keys[0])).toBe(calculateSlot(createCall[1].keys[1]));
expect(mockCache.set).not.toHaveBeenCalled();
});
it('waits through the full abandoned Redis lease before giving up', async () => {
jest.useFakeTimers();
const startedAt = Date.now();
mockRedisClient.set.mockImplementation(async () =>
Date.now() - startedAt >= 30_000 ? 'OK' : null,
);
const operation = jest.fn().mockResolvedValue('recovered');
const result = runWithGlobalCacheLock(operation);
await jest.advanceTimersByTimeAsync(5_000);
expect(operation).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(25_100);
await expect(result).resolves.toBe('recovered');
expect(operation).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,4 +1,4 @@
const { CacheKeys } = require('librechat-data-provider');
const { CacheKeys, Time } = require('librechat-data-provider');
jest.mock('~/cache/getLogStores');
const getLogStores = require('~/cache/getLogStores');
@ -9,76 +9,453 @@ getLogStores.mockReturnValue(mockCache);
const {
ToolCacheKeys,
getCachedTools,
updateCachedGlobalTools,
setCachedTools,
setCachedToolsIfCurrent,
getMCPToolsCacheGeneration,
renewMCPToolsCacheGeneration,
getCachedAppServerTools,
getNextAppToolsPublicationRevision,
setCachedAppServerTools,
runWithGlobalCacheLock,
invalidateCachedTools,
} = require('../getCachedTools');
describe('getCachedTools', () => {
describe('MCP tool cache', () => {
beforeEach(() => {
jest.clearAllMocks();
getLogStores.mockReturnValue(mockCache);
});
describe('ToolCacheKeys.MCP_SERVER', () => {
it('should generate cache keys that include userId', () => {
const key = ToolCacheKeys.MCP_SERVER('user123', 'github');
expect(key).toBe('tools:mcp:user123:github');
it('uses collision-safe configuration-addressed keys', () => {
expect(ToolCacheKeys.MCP_APP_SERVER('server:name', 'config/a')).toBe(
'tools:mcp:app:server%3Aname:config%2Fa',
);
expect(ToolCacheKeys.MCP_SERVER('tenant:user', 'server:name', 'config/a')).toBe(
'tools:mcp:user:{tenant%3Auser:server%3Aname}:config%2Fa',
);
expect(ToolCacheKeys.MCP_SERVER('tenant:user', 'server:name', 'config/a')).not.toBe(
ToolCacheKeys.MCP_SERVER('tenant', 'user:server:name', 'config/a'),
);
expect(ToolCacheKeys.MCP_SERVER_GENERATION('tenant:user', 'server:name')).toBe(
'tools:metadata:mcp:user-generation:{tenant%3Auser:server%3Aname}',
);
expect(ToolCacheKeys.MCP_SERVER_GENERATION('tenant:user', 'server:name')).not.toBe(
ToolCacheKeys.MCP_SERVER_GENERATION('tenant', 'user:server:name'),
);
expect(ToolCacheKeys.MCP_SERVER_LEGACY_FENCE('tenant:user', 'server:name')).toBe(
'tools:metadata:mcp:user-legacy-fence:{tenant%3Auser:server%3Aname}',
);
});
it('keeps the legacy user key available for non-generation callers', () => {
expect(ToolCacheKeys.MCP_SERVER('user123', 'github')).toBe('tools:mcp:user123:github');
});
it('gets and sets static global tools without touching MCP slices', async () => {
const tools = { builtin: { type: 'function' } };
mockCache.get.mockResolvedValue(tools);
mockCache.set.mockResolvedValue(true);
await expect(getCachedTools()).resolves.toBe(tools);
await expect(setCachedTools(tools)).resolves.toBe(true);
expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
expect(mockCache.set).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL, tools, expect.any(Number));
expect(mockCache.delete).not.toHaveBeenCalled();
});
it('updates the global catalog atomically through the catalog store', async () => {
const current = { builtin: { type: 'function' }, old_mcp_server: { type: 'function' } };
mockCache.get.mockResolvedValue(current);
mockCache.set.mockResolvedValue(true);
await updateCachedGlobalTools(({ old_mcp_server: _removed, ...staticTools }) => staticTools);
expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.GLOBAL,
{ builtin: { type: 'function' } },
Time.TWELVE_HOURS,
);
});
it('recreates the authoritative global catalog after its cache entry expires', async () => {
mockCache.get.mockResolvedValue(null);
mockCache.set.mockResolvedValue(true);
await updateCachedGlobalTools(() => ({ builtin: { type: 'function' } }));
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.GLOBAL,
{ builtin: { type: 'function' } },
Time.TWELVE_HOURS,
);
});
it('gets and sets an authoritative app slice, including an empty catalog', async () => {
mockCache.get.mockResolvedValue({});
mockCache.set.mockResolvedValue(true);
await expect(getCachedAppServerTools('github', 'config-v2')).resolves.toEqual({});
await expect(setCachedAppServerTools('github', 'config-v2', {})).resolves.toBe(true);
const key = ToolCacheKeys.MCP_APP_SERVER('github', 'config-v2');
expect(mockCache.get).toHaveBeenCalledWith(key);
expect(mockCache.set).toHaveBeenCalledWith(
key,
{ version: 1, publicationRevision: '0', tools: {} },
expect.any(Number),
);
});
it('prevents a slow older app snapshot from replacing a newer revision', async () => {
const key = ToolCacheKeys.MCP_APP_SERVER('github', 'config-v2');
let cached = null;
mockCache.get.mockImplementation(async (requestedKey) =>
requestedKey === key ? cached : null,
);
mockCache.set.mockImplementation(async (requestedKey, value) => {
if (requestedKey === key) {
cached = value;
}
return true;
});
const older = await getNextAppToolsPublicationRevision('github', 'config-v2');
const newer = await getNextAppToolsPublicationRevision('github', 'config-v2');
const currentTools = { current: { type: 'function' } };
const staleTools = { stale: { type: 'function' } };
await expect(setCachedAppServerTools('github', 'config-v2', currentTools, newer)).resolves.toBe(
true,
);
await expect(setCachedAppServerTools('github', 'config-v2', staleTools, older)).resolves.toBe(
false,
);
await expect(getCachedAppServerTools('github', 'config-v2')).resolves.toEqual(currentTools);
});
it('allows a completed snapshot when a later reserved request aborts', async () => {
const key = ToolCacheKeys.MCP_APP_SERVER('github', 'config-v2');
let cached = null;
mockCache.get.mockImplementation(async (requestedKey) =>
requestedKey === key ? cached : null,
);
mockCache.set.mockImplementation(async (requestedKey, value) => {
if (requestedKey === key) {
cached = value;
}
return true;
});
const completed = await getNextAppToolsPublicationRevision('github', 'config-v2');
await getNextAppToolsPublicationRevision('github', 'config-v2');
await expect(
setCachedAppServerTools('github', 'config-v2', { completed: {} }, completed),
).resolves.toBe(true);
await expect(getCachedAppServerTools('github', 'config-v2')).resolves.toEqual({
completed: {},
});
});
describe('TOOL_CACHE namespace usage', () => {
it('getCachedTools should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue(null);
await getCachedTools();
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
it('stores unguarded user tools under the supplied config generation', async () => {
const tools = { search: { type: 'function' } };
mockCache.set.mockResolvedValue(true);
await setCachedTools(tools, {
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
});
it('getCachedTools with MCP server options should use TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue({ tool1: {} });
await getCachedTools({ userId: 'user1', serverName: 'github' });
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
});
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github', 'config-v2'),
tools,
expect.any(Number),
);
});
it('setCachedTools should use TOOL_CACHE namespace', async () => {
mockCache.set.mockResolvedValue(true);
const tools = { tool1: { type: 'function' } };
await setCachedTools(tools);
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.set).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL, tools, expect.any(Number));
});
it('writes guarded user tools under both config and connection generations', async () => {
const tools = { current: { type: 'function' } };
mockCache.get.mockResolvedValue('connection-a');
mockCache.set.mockResolvedValue(true);
it('setCachedTools with MCP server options should use TOOL_CACHE namespace', async () => {
mockCache.set.mockResolvedValue(true);
const tools = { tool1: { type: 'function' } };
await setCachedTools(tools, { userId: 'user1', serverName: 'github' });
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github'),
await expect(
setCachedToolsIfCurrent(tools, {
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
publicationGeneration: 'connection-a',
}),
).resolves.toBe(true);
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github', 'config-v2'),
{ version: 1, publicationGeneration: 'connection-a', tools },
expect.any(Number),
);
});
it('reads guarded user tools only while their connection generation is current', async () => {
const tools = { current: { type: 'function' } };
mockCache.get
.mockResolvedValueOnce({
version: 1,
publicationGeneration: 'connection-a',
tools,
expect.any(Number),
);
});
})
.mockResolvedValueOnce('connection-a');
it('invalidateCachedTools should use TOOL_CACHE namespace', async () => {
mockCache.delete.mockResolvedValue(true);
await invalidateCachedTools({ invalidateGlobal: true });
expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE);
expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
});
await expect(
getCachedTools({
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
}),
).resolves.toEqual(tools);
it('should NOT use CONFIG_STORE namespace', async () => {
mockCache.get.mockResolvedValue(null);
await getCachedTools();
await getCachedTools({ userId: 'user1', serverName: 'github' });
mockCache.set.mockResolvedValue(true);
await setCachedTools({ tool1: {} });
mockCache.delete.mockResolvedValue(true);
await invalidateCachedTools({ invalidateGlobal: true });
expect(mockCache.get.mock.calls[0][0]).toBe(
ToolCacheKeys.MCP_SERVER('user1', 'github', 'config-v2'),
);
});
const allCalls = getLogStores.mock.calls.flat();
expect(allCalls).not.toContain(CacheKeys.CONFIG_STORE);
expect(allCalls.every((key) => key === CacheKeys.TOOL_CACHE)).toBe(true);
});
it('copies a legacy user catalog into the config-addressed key on rollout', async () => {
const tools = { legacy: { type: 'function' } };
mockCache.get
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(tools)
.mockResolvedValueOnce('connection-a')
.mockResolvedValueOnce('connection-a');
mockCache.set.mockResolvedValue(true);
await expect(
getCachedTools({
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
}),
).resolves.toBe(tools);
expect(mockCache.get).toHaveBeenNthCalledWith(4, ToolCacheKeys.MCP_SERVER('user1', 'github'));
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github', 'config-v2'),
{
version: 1,
publicationGeneration: 'connection-a',
tools,
},
Time.TWELVE_HOURS,
);
});
it('creates a generation fence before migrating a legacy user catalog', async () => {
const tools = { legacy: { type: 'function' } };
mockCache.get
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(tools)
.mockResolvedValueOnce(null)
.mockImplementationOnce(async () => mockCache.set.mock.calls[0][1]);
mockCache.set.mockResolvedValue(true);
await expect(
getCachedTools({
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
}),
).resolves.toBe(tools);
const [generationKey, generation] = mockCache.set.mock.calls[0];
expect(generationKey).toBe(ToolCacheKeys.MCP_SERVER_GENERATION('user1', 'github'));
expect(generation).toEqual(expect.any(String));
expect(mockCache.set.mock.calls[1][1]).toEqual(
expect.objectContaining({ publicationGeneration: generation, tools }),
);
});
it('rejects a legacy catalog recreated after the scope was fenced', async () => {
mockCache.get
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(true);
await expect(
getCachedTools({
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
}),
).resolves.toBeNull();
expect(mockCache.get).not.toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
expect(mockCache.set).not.toHaveBeenCalled();
});
it('preserves a config-addressed catalog published during legacy fallback', async () => {
const current = { current: { type: 'function' } };
mockCache.get.mockResolvedValueOnce(null).mockResolvedValueOnce(current);
await expect(
getCachedTools({
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
}),
).resolves.toBe(current);
expect(mockCache.get).toHaveBeenCalledTimes(2);
expect(mockCache.set).not.toHaveBeenCalled();
});
it('hides a guarded entry after its connection generation is replaced', async () => {
mockCache.get
.mockResolvedValueOnce({
version: 1,
publicationGeneration: 'connection-a',
tools: { stale: {} },
})
.mockResolvedValueOnce('connection-b');
await expect(
getCachedTools({
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v1',
}),
).resolves.toBeNull();
});
it('cannot let a late old-config write replace the current config key', async () => {
mockCache.get.mockResolvedValue('connection-a');
mockCache.set.mockResolvedValue(true);
await setCachedToolsIfCurrent(
{ current: {} },
{
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v2',
publicationGeneration: 'connection-a',
},
);
await setCachedToolsIfCurrent(
{ stale: {} },
{
userId: 'user1',
serverName: 'github',
configGeneration: 'config-v1',
publicationGeneration: 'connection-a',
},
);
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github', 'config-v2'),
expect.objectContaining({ tools: { current: {} } }),
expect.any(Number),
);
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER('user1', 'github', 'config-v1'),
expect.objectContaining({ tools: { stale: {} } }),
expect.any(Number),
);
});
it('creates and reuses a durable connection publication generation', async () => {
mockCache.get
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('existing-generation');
mockCache.set.mockResolvedValue(true);
const created = await getMCPToolsCacheGeneration({ userId: 'user1', serverName: 'github' });
const existing = await getMCPToolsCacheGeneration({ userId: 'user1', serverName: 'github' });
expect(created).toEqual(expect.any(String));
expect(existing).toBe('existing-generation');
expect(mockCache.set).toHaveBeenCalledWith(
ToolCacheKeys.MCP_SERVER_GENERATION('user1', 'github'),
created,
expect.any(Number),
);
});
it('renews a lease only for its current publication generation', async () => {
mockCache.get.mockResolvedValue('connection-a');
mockCache.set.mockResolvedValue(true);
await expect(
renewMCPToolsCacheGeneration({
userId: 'user1',
serverName: 'github',
publicationGeneration: 'connection-a',
}),
).resolves.toBe(true);
await expect(
renewMCPToolsCacheGeneration({
userId: 'user1',
serverName: 'github',
publicationGeneration: 'connection-b',
}),
).resolves.toBe(false);
});
it('rotates the connection generation before deleting the legacy user key', async () => {
mockCache.set.mockResolvedValue(true);
mockCache.delete.mockResolvedValue(true);
await invalidateCachedTools({ userId: 'user1', serverName: 'github' });
expect(mockCache.set).toHaveBeenNthCalledWith(
1,
ToolCacheKeys.MCP_SERVER_LEGACY_FENCE('user1', 'github'),
true,
expect.any(Number),
);
expect(mockCache.set.mock.calls[0][2]).toBeGreaterThanOrEqual(Time.ONE_DAY);
expect(mockCache.set).toHaveBeenNthCalledWith(
2,
ToolCacheKeys.MCP_SERVER_GENERATION('user1', 'github'),
expect.any(String),
expect.any(Number),
);
expect(mockCache.set.mock.calls[1][2]).toBeGreaterThanOrEqual(Time.ONE_DAY);
expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github'));
expect(mockCache.set.mock.invocationCallOrder[1]).toBeLessThan(
mockCache.delete.mock.invocationCallOrder[0],
);
});
it('invalidates only the static global key for broad config changes', async () => {
mockCache.delete.mockResolvedValue(true);
await invalidateCachedTools({ invalidateGlobal: true });
expect(mockCache.delete).toHaveBeenCalledTimes(1);
expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
});
it('runs global cache operations directly when the cache is in memory', async () => {
const operation = jest.fn().mockResolvedValue('done');
await expect(runWithGlobalCacheLock(operation)).resolves.toBe('done');
expect(operation).toHaveBeenCalledTimes(1);
});
it('uses only the TOOL_CACHE namespace', async () => {
mockCache.get.mockResolvedValue(null);
mockCache.set.mockResolvedValue(true);
mockCache.delete.mockResolvedValue(true);
await getCachedTools();
await getCachedAppServerTools('github', 'config-v2');
await setCachedTools({});
await invalidateCachedTools({ invalidateGlobal: true });
expect(getLogStores.mock.calls.flat().every((key) => key === CacheKeys.TOOL_CACHE)).toBe(true);
});
});

View file

@ -1,89 +1,20 @@
const { CacheKeys, Time } = require('librechat-data-provider');
const { CacheKeys } = require('librechat-data-provider');
const {
cacheConfig,
ioredisClient,
keyvRedisClient,
mcpConfig,
ToolCacheKeys,
createMCPCatalogStore,
} = require('@librechat/api');
const getLogStores = require('~/cache/getLogStores');
/**
* Cache key generators for different tool access patterns
*/
const ToolCacheKeys = {
/** Global tools available to all users */
GLOBAL: 'tools:global',
/** MCP tools cached by user ID and server name */
MCP_SERVER: (userId, serverName) => `tools:mcp:${userId}:${serverName}`,
};
const store = createMCPCatalogStore({
cacheConfig,
ioredisClient,
keyvRedisClient,
userConnectionIdleTimeout: mcpConfig.USER_CONNECTION_IDLE_TIMEOUT,
getCache: () => getLogStores(CacheKeys.TOOL_CACHE),
});
/**
* Retrieves available tools from cache
* @function getCachedTools
* @param {Object} options - Options for retrieving tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name to get cached tools for
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName } = options;
// Return MCP server-specific tools if requested
if (serverName && userId) {
return await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName));
}
// Default to global tools
return await cache.get(ToolCacheKeys.GLOBAL);
}
/**
* Sets available tools in cache
* @function setCachedTools
* @param {Object} tools - The tools object to cache
* @param {Object} options - Options for caching tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name for server-specific tools
* @param {number} [options.ttl] - Time to live in milliseconds (default: 12 hours)
* @returns {Promise<boolean>} Whether the operation was successful
*/
async function setCachedTools(tools, options = {}) {
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName, ttl = Time.TWELVE_HOURS } = options;
// Cache by MCP server if specified (requires userId)
if (serverName && userId) {
return await cache.set(ToolCacheKeys.MCP_SERVER(userId, serverName), tools, ttl);
}
// Default to global cache
return await cache.set(ToolCacheKeys.GLOBAL, tools, ttl);
}
/**
* Invalidates cached tools
* @function invalidateCachedTools
* @param {Object} options - Options for invalidating tools
* @param {string} [options.userId] - User ID for user-specific MCP tools
* @param {string} [options.serverName] - MCP server name to invalidate
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
* @returns {Promise<void>}
*/
async function invalidateCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.TOOL_CACHE);
const { userId, serverName, invalidateGlobal = false } = options;
const keysToDelete = [];
if (invalidateGlobal) {
keysToDelete.push(ToolCacheKeys.GLOBAL);
}
if (serverName && userId) {
keysToDelete.push(ToolCacheKeys.MCP_SERVER(userId, serverName));
}
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
}
module.exports = {
ToolCacheKeys,
getCachedTools,
setCachedTools,
invalidateCachedTools,
};
module.exports = { ToolCacheKeys, ...store };

View file

@ -1,17 +1,43 @@
const { createMCPToolCacheService, MCPServersRegistry } = require('@librechat/api');
const { getCachedTools, setCachedTools } = require('./getCachedTools');
const {
getCachedTools,
updateCachedGlobalTools,
setCachedToolsWithinGlobalLock,
getCachedAppServerTools,
setCachedAppServerTools,
setCachedToolsIfCurrent,
getMCPToolsCacheGeneration,
renewMCPToolsCacheGeneration,
getNextAppToolsPublicationRevision,
} = require('./getCachedTools');
const { mergeAppTools, cacheMCPServerTools, updateMCPServerTools, getMCPServerTools } =
createMCPToolCacheService({
getCachedTools,
setCachedTools,
getServerConfig: (serverName, userId) =>
MCPServersRegistry.getInstance().getServerConfig(serverName, userId),
});
const {
syncStaticTools,
mergeAppTools,
cacheMCPServerTools,
updateMCPServerTools,
getMCPServerTools,
} = createMCPToolCacheService({
getCachedTools,
updateCachedGlobalTools,
setCachedTools: setCachedToolsWithinGlobalLock,
setCachedToolsIfCurrent,
getCachedAppServerTools,
setCachedAppServerTools,
getServerConfig: (serverName, userId) =>
MCPServersRegistry.getInstance().getServerConfig(serverName, userId),
getAllServerConfigs: () => MCPServersRegistry.getInstance().getAllServerConfigs(),
isAppServerConfig: (serverName, effectiveConfig) =>
MCPServersRegistry.getInstance().isAppServerConfig(serverName, effectiveConfig),
});
module.exports = {
syncStaticTools,
mergeAppTools,
getMCPServerTools,
cacheMCPServerTools,
updateMCPServerTools,
getMCPToolsCacheGeneration,
renewMCPToolsCacheGeneration,
getNextAppToolsPublicationRevision,
};

View file

@ -12,6 +12,7 @@ const {
normalizeMCPToolKey,
buildServerNameAliases,
findShadowedServerNames,
getAssistantToolDefinitions: loadAssistantToolDefinitions,
resolveMCPServerContext,
normalizeJsonSchema,
GenerationJobManager,
@ -27,6 +28,7 @@ const {
isUserSourced,
checkAccessWithRequestCache,
getMissingCustomUserVars,
getUserMCPAuthMap,
getServerCustomUserVars,
requiresEphemeralUserConnection,
requiresOAuthMachinery,
@ -49,12 +51,17 @@ const {
getMCPManager,
} = require('~/config');
const db = require('~/models');
const { findToken, createToken, updateToken, deleteTokens } = db;
const { findToken, createToken, updateToken, deleteTokens, findPluginAuthsByKeys } = db;
const { getGraphApiToken } = require('./GraphTokenService');
const { exchangeOboToken } = require('./OboTokenService');
const { createOboTrustChecker } = require('./OboPolicyService');
const { reinitMCPServer } = require('./Tools/mcp');
const { getAppConfig } = require('./Config');
const {
getAppConfig,
getCachedTools,
getMCPServerTools,
cacheMCPServerTools,
} = require('./Config');
const { getLogStores } = require('~/cache');
const MAX_CACHE_SIZE = 1000;
@ -303,6 +310,57 @@ async function healMcpToolNames({ req, tools, toolDefinitions }) {
return healedList;
}
/**
* Loads static and MCP function definitions used by assistant create/update writes. MCP catalogs
* are stored per server and effective config, so assistant writers must resolve the referenced
* server slices instead of relying on the static aggregate cache.
* @param {object} params
* @param {ServerRequest} params.req
* @param {Array<string | object>} [params.tools]
* @returns {Promise<object>}
*/
async function getAssistantToolDefinitions({ req, tools }) {
const registry = getMCPServersRegistry();
const appConfig = await getAppConfigForRequest(req);
return await loadAssistantToolDefinitions(
{
user: req.user,
tools,
staticTools: (await getCachedTools()) ?? {},
mcpConfig: appConfig?.mcpConfig ?? {},
},
{
ensureConfigServers: (mcpConfig) => registry.ensureConfigServers(mcpConfig),
getAllServerConfigs: (userId, configServers, role) =>
registry.getAllServerConfigs(userId, configServers, role),
getMCPServerTools,
getServerToolFunctionsSnapshot: async (userId, serverName, serverConfig) =>
(await getMCPManager()?.getServerToolFunctionsSnapshot(
userId,
serverName,
serverConfig,
)) ?? {
tools: null,
},
recoverServerTools: async (serverName, serverConfig) => {
const userMCPAuthMap = await getUserMCPAuthMap({
userId: req.user.id,
servers: [serverName],
findPluginAuthsByKeys,
});
const result = await reinitMCPServer({
user: req.user,
serverName,
serverConfig,
userMCPAuthMap,
});
return result?.availableTools ?? null;
},
cacheMCPServerTools,
},
);
}
/**
* Resolves the name set MCP collision guards audit against. Prefers the
* caller-threaded accessible set; self-fetches only when a configured name
@ -1431,6 +1489,7 @@ module.exports = {
resolveMcpServerContext,
getAccessibleMcpServerNames,
healMcpToolNames,
getAssistantToolDefinitions,
resolveCollisionAuditNames,
resolveMcpConfigNames,
resolveAllMcpConfigs,

View file

@ -10,7 +10,7 @@ const { findToken, createToken, updateToken, deleteTokens } = require('~/models'
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { exchangeOboToken } = require('~/server/services/OboTokenService');
const { createOboTrustChecker } = require('~/server/services/OboPolicyService');
const { updateMCPServerTools } = require('~/server/services/Config');
const { getMCPToolsCacheGeneration, updateMCPServerTools } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
const MCP_REINITIALIZE_FAILURE_REASONS = {
@ -65,6 +65,7 @@ async function reinitMCPServer({
let oauthUrl = null;
let oauthExpiresAt;
let ephemeralServer = false;
let publicationGeneration;
try {
const registry = getMCPServersRegistry();
@ -167,6 +168,13 @@ async function reinitMCPServer({
const mcpManager = getMCPManager();
const tokenMethods = { findToken, updateToken, createToken, deleteTokens };
if (!ephemeralServer) {
publicationGeneration = await getMCPToolsCacheGeneration({
userId: user.id,
serverName,
});
}
const oauthStart =
_oauthStart ??
(async (authURL, options) => {
@ -259,7 +267,36 @@ async function reinitMCPServer({
}
if (connection && !oauthRequired) {
tools = await connection.fetchTools();
publicationGeneration =
mcpManager.getToolPublicationGeneration(connection) ?? publicationGeneration;
let snapshot;
if (typeof connection.fetchOrderedToolsSnapshot === 'function') {
snapshot = await connection.fetchOrderedToolsSnapshot();
} else if (typeof connection.fetchToolsSnapshot === 'function') {
snapshot = await connection.fetchToolsSnapshot();
} else {
snapshot = { tools: await connection.fetchTools(), complete: true };
}
if (snapshot.complete) {
tools = snapshot.tools;
} else {
logger.warn(
`[MCP Reinitialize] Preserving cached tools for ${serverName} because tools/list returned an incomplete snapshot`,
);
}
}
if (tools && !ephemeralServer && publicationGeneration) {
const currentGeneration = await getMCPToolsCacheGeneration({
userId: user.id,
serverName,
});
if (currentGeneration !== publicationGeneration) {
logger.warn(
`[MCP Reinitialize] Discarding stale tools for ${serverName} because its publication generation changed during discovery`,
);
tools = null;
}
}
if (tools) {
@ -268,7 +305,11 @@ async function reinitMCPServer({
serverName,
tools,
serverConfig,
...(publicationGeneration && { publicationGeneration }),
});
if (availableTools == null) {
tools = null;
}
}
logger.debug(
@ -325,12 +366,9 @@ async function reinitMCPServer({
} finally {
if (connection && ephemeralServer && !requestScopedConnections) {
try {
await connection.disconnect();
await connection.dispose();
} catch (error) {
logger.warn(
`[MCP Reinitialize] Failed to disconnect ephemeral server ${serverName}`,
error,
);
logger.warn(`[MCP Reinitialize] Failed to dispose ephemeral server ${serverName}`, error);
}
}
}

View file

@ -4,11 +4,14 @@ const mockGetConnection = jest.fn();
const mockDiscoverServerTools = jest.fn();
const mockGetGraphApiToken = jest.fn();
const mockUpdateMCPServerTools = jest.fn();
const mockGetMCPToolsCacheGeneration = jest.fn().mockResolvedValue('generation-current');
const mockGetToolPublicationGeneration = jest.fn().mockReturnValue('generation-current');
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
getConnection: mockGetConnection,
discoverServerTools: mockDiscoverServerTools,
getToolPublicationGeneration: mockGetToolPublicationGeneration,
})),
getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })),
getFlowStateManager: jest.fn(() => ({})),
@ -21,6 +24,7 @@ jest.mock('~/models', () => ({
}));
jest.mock('~/server/services/Config', () => ({
updateMCPServerTools: mockUpdateMCPServerTools,
getMCPToolsCacheGeneration: mockGetMCPToolsCacheGeneration,
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: mockGetGraphApiToken,
@ -117,9 +121,71 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
serverName,
tools: [],
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
publicationGeneration: 'generation-current',
});
});
it('preserves cached tools when live recovery returns an incomplete snapshot', async () => {
const fetchOrderedToolsSnapshot = jest.fn().mockResolvedValue({
tools: [{ name: 'partial', inputSchema: { type: 'object' } }],
complete: false,
});
mockGetConnection.mockResolvedValue({
fetchOrderedToolsSnapshot,
});
const result = await reinitMCPServer({
user,
serverName,
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
});
expect(result.tools).toBeNull();
expect(fetchOrderedToolsSnapshot).toHaveBeenCalledTimes(1);
expect(mockUpdateMCPServerTools).not.toHaveBeenCalled();
});
it('discards a snapshot when another replica rotates its generation during discovery', async () => {
mockGetMCPToolsCacheGeneration
.mockResolvedValueOnce('generation-current')
.mockResolvedValueOnce('generation-replaced');
mockGetConnection.mockResolvedValue({
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
tools: [{ name: 'stale', inputSchema: { type: 'object' } }],
complete: true,
}),
});
const result = await reinitMCPServer({
user,
serverName,
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
});
expect(result.tools).toBeNull();
expect(result.availableTools).toBeNull();
expect(mockUpdateMCPServerTools).not.toHaveBeenCalled();
});
it('does not return tools when the guarded publication loses its generation race', async () => {
mockGetConnection.mockResolvedValue({
fetchOrderedToolsSnapshot: jest.fn().mockResolvedValue({
tools: [{ name: 'stale', inputSchema: { type: 'object' } }],
complete: true,
}),
});
mockUpdateMCPServerTools.mockResolvedValue(null);
const result = await reinitMCPServer({
user,
serverName,
serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' },
});
expect(result.tools).toBeNull();
expect(result.availableTools).toBeNull();
});
it('passes request body and Graph resolver into connection creation', async () => {
mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
@ -167,8 +233,8 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
);
});
it('disconnects ephemeral BODY-scoped connections after loading tools', async () => {
const disconnect = jest.fn().mockResolvedValue(undefined);
it('disposes ephemeral BODY-scoped connections after loading tools', async () => {
const dispose = jest.fn().mockResolvedValue(undefined);
const tools = [{ name: 'search', inputSchema: { type: 'object', properties: {} } }];
const serverConfig = {
type: 'streamable-http',
@ -176,7 +242,7 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
source: 'yaml',
};
mockGetConnection.mockResolvedValue({
disconnect,
dispose,
fetchTools: jest.fn().mockResolvedValue(tools),
});
@ -188,7 +254,7 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => {
userMCPAuthMap: undefined,
});
expect(disconnect).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith(
expect.objectContaining({
tools,
@ -261,9 +327,8 @@ describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)'
});
it('connects normally when the request body provides the placeholder fields', async () => {
const disconnect = jest.fn().mockResolvedValue(undefined);
mockGetConnection.mockResolvedValue({
disconnect,
dispose: jest.fn().mockResolvedValue(undefined),
fetchTools: jest.fn().mockResolvedValue([]),
});

View file

@ -20,6 +20,7 @@ jest.mock('~/server/services/Config', () => ({
setCachedTools: jest.fn(),
getCachedTools: jest.fn(),
getMCPServerTools: jest.fn(),
cacheMCPServerTools: jest.fn(),
loadCustomConfig: jest.fn(),
}));
@ -32,6 +33,7 @@ jest.mock('@librechat/api', () => ({
isMCPDomainAllowed: jest.fn(),
GenerationJobManager: jest.fn(),
buildOAuthToolCallName: jest.fn((name) => name),
getUserMCPAuthMap: jest.fn(),
/** Mirrors the real resolver so these tests still exercise the wrapper's own
* plumbing - loading the request config and degrading on failure - rather than
* the resolution logic, which is unit-tested in packages/api. Like the real
@ -53,6 +55,7 @@ jest.mock('~/models', () => ({
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
findPluginAuthsByKeys: jest.fn(),
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: jest.fn(),
@ -69,11 +72,18 @@ jest.mock('~/server/services/Tools/mcp', () => ({
const { Constants } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config');
const {
getAppConfig,
getCachedTools,
getMCPServerTools,
cacheMCPServerTools,
} = require('~/server/services/Config');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { getUserMCPAuthMap } = require('@librechat/api');
const {
createMCPTool,
healMcpToolNames,
getAssistantToolDefinitions,
resolveConfigServers,
resolveMcpConfigNames,
resolveAllMcpConfigs,
@ -81,6 +91,105 @@ const {
resolveCollisionAuditNames,
} = require('../MCP');
describe('getAssistantToolDefinitions', () => {
beforeEach(() => {
jest.clearAllMocks();
require('~/config').getMCPManager.mockReset();
});
const req = { user: { id: 'u1', role: 'user' } };
const serverConfig = { type: 'streamable-http', url: 'https://app.example.com/mcp' };
const toolKey = `search${Constants.mcp_delimiter}app-server`;
const mcpDefinition = { type: 'function', function: { name: toolKey } };
it('combines static definitions with referenced configuration-addressed MCP slices', async () => {
getCachedTools.mockResolvedValue({ code_interpreter: { type: 'code_interpreter' } });
getAppConfig.mockResolvedValue({ mcpConfig: {} });
mockRegistry.ensureConfigServers.mockResolvedValue({});
mockRegistry.getAllServerConfigs.mockResolvedValue({ 'app-server': serverConfig });
getMCPServerTools.mockResolvedValue({ [toolKey]: mcpDefinition });
const definitions = await getAssistantToolDefinitions({
req,
tools: ['code_interpreter', toolKey],
});
expect(definitions).toEqual({
code_interpreter: { type: 'code_interpreter' },
[toolKey]: mcpDefinition,
});
expect(getMCPServerTools).toHaveBeenCalledWith('u1', 'app-server', serverConfig);
});
it('recovers and re-caches a referenced server when its slice is missing', async () => {
getCachedTools.mockResolvedValue({});
getAppConfig.mockResolvedValue({ mcpConfig: {} });
mockRegistry.ensureConfigServers.mockResolvedValue({});
mockRegistry.getAllServerConfigs.mockResolvedValue({ 'app-server': serverConfig });
getMCPServerTools.mockResolvedValue(null);
cacheMCPServerTools.mockResolvedValue(undefined);
const getServerToolFunctionsSnapshot = jest.fn().mockResolvedValue({
tools: { [toolKey]: mcpDefinition },
publicationGeneration: 'connection-generation',
});
require('~/config').getMCPManager.mockReturnValue({ getServerToolFunctionsSnapshot });
await expect(getAssistantToolDefinitions({ req, tools: [toolKey] })).resolves.toEqual({
[toolKey]: mcpDefinition,
});
expect(cacheMCPServerTools).toHaveBeenCalledWith({
userId: 'u1',
serverName: 'app-server',
serverTools: { [toolKey]: mcpDefinition },
serverConfig,
publicationGeneration: 'connection-generation',
});
});
it('reinitializes a referenced server when its cache and local snapshot are missing', async () => {
getCachedTools.mockResolvedValue({});
getAppConfig.mockResolvedValue({ mcpConfig: {} });
mockRegistry.ensureConfigServers.mockResolvedValue({});
mockRegistry.getAllServerConfigs.mockResolvedValue({ 'app-server': serverConfig });
getMCPServerTools.mockResolvedValue(null);
const getServerToolFunctionsSnapshot = jest.fn().mockResolvedValue({ tools: null });
require('~/config').getMCPManager.mockReturnValue({ getServerToolFunctionsSnapshot });
const userMCPAuthMap = { 'mcp_app-server': { API_KEY: 'saved' } };
getUserMCPAuthMap.mockResolvedValue(userMCPAuthMap);
reinitMCPServer.mockResolvedValue({ availableTools: { [toolKey]: mcpDefinition } });
await expect(getAssistantToolDefinitions({ req, tools: [toolKey] })).resolves.toEqual({
[toolKey]: mcpDefinition,
});
expect(reinitMCPServer).toHaveBeenCalledWith({
user: req.user,
serverName: 'app-server',
serverConfig,
userMCPAuthMap,
});
expect(getUserMCPAuthMap).toHaveBeenCalledWith({
userId: 'u1',
servers: ['app-server'],
findPluginAuthsByKeys: expect.any(Function),
});
});
it('propagates config-server resolution failures through the assistant write bridge', async () => {
const resolutionError = new Error('config resolution failed');
getCachedTools.mockResolvedValue({});
getAppConfig.mockResolvedValue({
mcpConfig: { 'app-server': { type: 'streamable-http', url: 'https://example.com/mcp' } },
});
mockRegistry.ensureConfigServers.mockRejectedValue(resolutionError);
await expect(getAssistantToolDefinitions({ req, tools: [toolKey] })).rejects.toBe(
resolutionError,
);
expect(mockRegistry.getAllServerConfigs).not.toHaveBeenCalled();
expect(getMCPServerTools).not.toHaveBeenCalled();
});
});
describe('resolveConfigServers', () => {
beforeEach(() => jest.clearAllMocks());

View file

@ -1,6 +1,19 @@
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const { mergeAppTools, getAppConfig } = require('./Config');
const {
registerShutdownTask,
setMCPToolsChangedHandler,
setMCPToolsChangedGenerationHandler,
setMCPToolsChangedGenerationRenewalHandler,
setMCPToolsChangedRevisionHandler,
} = require('@librechat/api');
const { syncStaticTools, mergeAppTools, getAppConfig } = require('./Config');
const {
getMCPToolsCacheGeneration,
renewMCPToolsCacheGeneration,
getNextAppToolsPublicationRevision,
updateMCPServerTools,
} = require('./Config/mcp');
const { createMCPServersRegistry, createMCPManager } = require('~/config');
/**
@ -18,6 +31,36 @@ async function resolveMCPAllowlists(ctx) {
};
}
/**
* Refreshes one server's tools after it reported `notifications/tools/list_changed`.
*
* A server that builds tools at runtime is the case this exists for: without it the tool list
* stayed frozen at connection time and only a restart picked up the change (#7117). The list is
* re-fetched from the live connection and written over that server's cache entry, so tools that
* disappeared stop being advertised too.
*/
async function refreshChangedServerTools({
serverName,
userId,
tools,
serverConfig,
publicationGeneration,
publicationRevision,
}) {
await updateMCPServerTools({
userId,
serverName,
tools,
serverConfig,
...(publicationGeneration && { publicationGeneration }),
...(publicationRevision && { publicationRevision }),
});
const toolCount = tools.length;
logger.info(
`[MCP][${serverName}] Tool list changed; refreshed ${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}${userId ? ` for user ${userId}` : ''}`,
);
}
/**
* Initialize MCP servers
*/
@ -39,16 +82,28 @@ async function initializeMCPs() {
try {
const mcpManager = await createMCPManager(mcpServers || {});
setMCPToolsChangedHandler(refreshChangedServerTools);
setMCPToolsChangedGenerationHandler(getMCPToolsCacheGeneration);
setMCPToolsChangedGenerationRenewalHandler(renewMCPToolsCacheGeneration);
setMCPToolsChangedRevisionHandler(({ serverName, configGeneration }) =>
getNextAppToolsPublicationRevision(serverName, configGeneration),
);
registerShutdownTask('MCP app connections', () => mcpManager.disconnectAppServers());
if (mcpServers && Object.keys(mcpServers).length > 0) {
const mcpTools = (await mcpManager.getAppToolFunctions()) || {};
await mergeAppTools(mcpTools);
try {
await mergeAppTools(mcpTools, appConfig.availableTools || {});
} finally {
await mcpManager.connectAppServers();
}
const serverCount = Object.keys(mcpServers).length;
const toolCount = Object.keys(mcpTools).length;
logger.info(
`[MCP] Initialized with ${serverCount} configured ${serverCount === 1 ? 'server' : 'servers'} and ${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}.`,
);
} else {
await syncStaticTools(appConfig.availableTools || {});
logger.debug('[MCP] No servers configured. MCPManager ready for UI-based servers.');
}
} catch (error) {
@ -58,3 +113,4 @@ async function initializeMCPs() {
}
module.exports = initializeMCPs;
module.exports.refreshChangedServerTools = refreshChangedServerTools;

View file

@ -27,6 +27,7 @@ jest.mock('@librechat/data-schemas', () => ({
// Mock config functions
const mockGetAppConfig = jest.fn();
const mockSyncStaticTools = jest.fn();
const mockMergeAppTools = jest.fn();
jest.mock('./Config', () => ({
@ -36,12 +37,17 @@ jest.mock('./Config', () => ({
get mergeAppTools() {
return mockMergeAppTools;
},
get syncStaticTools() {
return mockSyncStaticTools;
},
}));
// Mock MCP singletons
const mockCreateMCPServersRegistry = jest.fn();
const mockCreateMCPManager = jest.fn();
const mockMCPManagerInstance = {
connectAppServers: jest.fn(),
disconnectAppServers: jest.fn(),
getAppToolFunctions: jest.fn(),
};
@ -54,6 +60,49 @@ jest.mock('~/config', () => ({
},
}));
const mockSetMCPToolsChangedHandler = jest.fn();
const mockSetMCPToolsChangedGenerationHandler = jest.fn();
const mockSetMCPToolsChangedGenerationRenewalHandler = jest.fn();
const mockSetMCPToolsChangedRevisionHandler = jest.fn();
const mockRegisterShutdownTask = jest.fn();
const mockUpdateMCPServerTools = jest.fn();
const mockGetMCPToolsCacheGeneration = jest.fn();
const mockRenewMCPToolsCacheGeneration = jest.fn();
const mockGetNextAppToolsPublicationRevision = jest.fn();
jest.mock('@librechat/api', () => ({
get registerShutdownTask() {
return mockRegisterShutdownTask;
},
get setMCPToolsChangedHandler() {
return mockSetMCPToolsChangedHandler;
},
get setMCPToolsChangedGenerationHandler() {
return mockSetMCPToolsChangedGenerationHandler;
},
get setMCPToolsChangedGenerationRenewalHandler() {
return mockSetMCPToolsChangedGenerationRenewalHandler;
},
get setMCPToolsChangedRevisionHandler() {
return mockSetMCPToolsChangedRevisionHandler;
},
}));
jest.mock('./Config/mcp', () => ({
get updateMCPServerTools() {
return mockUpdateMCPServerTools;
},
get getMCPToolsCacheGeneration() {
return mockGetMCPToolsCacheGeneration;
},
get renewMCPToolsCacheGeneration() {
return mockRenewMCPToolsCacheGeneration;
},
get getNextAppToolsPublicationRevision() {
return mockGetNextAppToolsPublicationRevision;
},
}));
const { logger } = require('@librechat/data-schemas');
const initializeMCPs = require('./initializeMCPs');
@ -65,6 +114,9 @@ describe('initializeMCPs', () => {
mockCreateMCPServersRegistry.mockReturnValue(undefined);
mockCreateMCPManager.mockResolvedValue(mockMCPManagerInstance);
mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue({});
mockMCPManagerInstance.connectAppServers.mockResolvedValue(undefined);
mockMCPManagerInstance.disconnectAppServers.mockResolvedValue(undefined);
mockSyncStaticTools.mockResolvedValue(undefined);
mockMergeAppTools.mockResolvedValue(undefined);
});
@ -183,6 +235,36 @@ describe('initializeMCPs', () => {
expect(mockCreateMCPManager).toHaveBeenCalledWith(mcpServers);
});
it('should register app connections for graceful shutdown', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
await initializeMCPs();
expect(mockRegisterShutdownTask).toHaveBeenCalledWith(
'MCP app connections',
expect.any(Function),
);
const shutdown = mockRegisterShutdownTask.mock.calls[0][1];
await shutdown();
expect(mockMCPManagerInstance.disconnectAppServers).toHaveBeenCalledTimes(1);
});
it('should wire app publication revision allocation into the cache store', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: null });
mockGetNextAppToolsPublicationRevision.mockResolvedValue('9');
await initializeMCPs();
const allocateRevision = mockSetMCPToolsChangedRevisionHandler.mock.calls[0][0];
await expect(
allocateRevision({ serverName: 'dynamic', configGeneration: 'config-generation' }),
).resolves.toBe('9');
expect(mockGetNextAppToolsPublicationRevision).toHaveBeenCalledWith(
'dynamic',
'config-generation',
);
});
it('should throw and log error if MCPManager initialization fails', async () => {
const managerError = new Error('Manager initialization failed');
mockCreateMCPManager.mockRejectedValue(managerError);
@ -197,21 +279,24 @@ describe('initializeMCPs', () => {
});
describe('Tool merging behavior', () => {
it('should NOT merge tools when no configured servers exist', async () => {
it('should skip app catalog discovery when no configured servers exist', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: null, // No configured servers
availableTools: { builtin: { type: 'function' } },
});
await initializeMCPs();
expect(mockMCPManagerInstance.getAppToolFunctions).not.toHaveBeenCalled();
expect(mockMergeAppTools).not.toHaveBeenCalled();
expect(mockSyncStaticTools).toHaveBeenCalledWith({ builtin: { type: 'function' } });
expect(mockMCPManagerInstance.connectAppServers).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'[MCP] No servers configured. MCPManager ready for UI-based servers.',
);
});
it('should NOT merge tools when mcpConfig is empty object', async () => {
it('should skip app catalog discovery when mcpConfig is empty', async () => {
mockGetAppConfig.mockResolvedValue({
mcpConfig: {}, // Empty object
});
@ -220,6 +305,8 @@ describe('initializeMCPs', () => {
expect(mockMCPManagerInstance.getAppToolFunctions).not.toHaveBeenCalled();
expect(mockMergeAppTools).not.toHaveBeenCalled();
expect(mockSyncStaticTools).toHaveBeenCalledWith({});
expect(mockMCPManagerInstance.connectAppServers).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'[MCP] No servers configured. MCPManager ready for UI-based servers.',
);
@ -239,7 +326,11 @@ describe('initializeMCPs', () => {
await initializeMCPs();
expect(mockMCPManagerInstance.getAppToolFunctions).toHaveBeenCalledTimes(1);
expect(mockMergeAppTools).toHaveBeenCalledWith(mcpTools);
expect(mockMergeAppTools).toHaveBeenCalledWith(mcpTools, {});
expect(mockMCPManagerInstance.connectAppServers).toHaveBeenCalledTimes(1);
expect(mockMergeAppTools.mock.invocationCallOrder[0]).toBeLessThan(
mockMCPManagerInstance.connectAppServers.mock.invocationCallOrder[0],
);
expect(logger.info).toHaveBeenCalledWith(
'[MCP] Initialized with 1 configured server and 2 tools.',
);
@ -253,11 +344,21 @@ describe('initializeMCPs', () => {
await initializeMCPs();
// Should use empty object fallback
expect(mockMergeAppTools).toHaveBeenCalledWith({});
expect(mockMergeAppTools).toHaveBeenCalledWith({}, {});
expect(logger.info).toHaveBeenCalledWith(
'[MCP] Initialized with 1 configured server and 0 tools.',
);
});
it('should connect app servers when startup cache synchronization fails', async () => {
const mcpServers = { 'test-server': { type: 'sse', url: 'http://localhost:3001' } };
mockGetAppConfig.mockResolvedValue({ mcpConfig: mcpServers });
mockMergeAppTools.mockRejectedValueOnce(new Error('cache lock timed out'));
await expect(initializeMCPs()).rejects.toThrow('cache lock timed out');
expect(mockMCPManagerInstance.connectAppServers).toHaveBeenCalledTimes(1);
});
});
describe('Initialization order', () => {
@ -315,3 +416,45 @@ describe('initializeMCPs', () => {
});
});
});
describe('refreshChangedServerTools', () => {
const { refreshChangedServerTools } = require('./initializeMCPs');
const event = {
serverName: 'dynamic',
serverConfig: { type: 'streamable-http', url: 'https://mcp.example.com' },
tools: [{ name: 'tool', inputSchema: { type: 'object' } }],
};
beforeEach(() => {
jest.clearAllMocks();
});
it('publishes the complete refreshed snapshot in its original cache scope', async () => {
await refreshChangedServerTools({ ...event, userId: 'user-1' });
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith({ ...event, userId: 'user-1' });
expect(logger.info).toHaveBeenCalledWith(
'[MCP][dynamic] Tool list changed; refreshed 1 tool for user user-1',
);
});
it('publishes an empty app-level snapshot so removals take effect', async () => {
await refreshChangedServerTools({ ...event, tools: [] });
expect(mockUpdateMCPServerTools).toHaveBeenCalledWith({ ...event, tools: [] });
});
it('is registered as the tools-changed handler during initialization', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: null, mcpSettings: {} });
await initializeMCPs();
expect(mockSetMCPToolsChangedHandler).toHaveBeenCalledWith(refreshChangedServerTools);
expect(mockSetMCPToolsChangedGenerationHandler).toHaveBeenCalledWith(
mockGetMCPToolsCacheGeneration,
);
expect(mockSetMCPToolsChangedGenerationRenewalHandler).toHaveBeenCalledWith(
mockRenewMCPToolsCacheGeneration,
);
});
});