mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
* 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>
359 lines
11 KiB
JavaScript
359 lines
11 KiB
JavaScript
const mockGetMCPManager = jest.fn();
|
|
const mockInvalidateCachedTools = jest.fn();
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: (...args) => mockGetMCPManager(...args),
|
|
getFlowStateManager: jest.fn(),
|
|
getMCPServersRegistry: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
|
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
getAppConfig: jest.fn(),
|
|
getMCPServerTools: jest.fn(),
|
|
}));
|
|
|
|
const mongoose = require('mongoose');
|
|
const { mcpServerSchema } = require('@librechat/data-schemas');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
const {
|
|
ResourceType,
|
|
AccessRoleIds,
|
|
PrincipalType,
|
|
PermissionBits,
|
|
} = require('librechat-data-provider');
|
|
const permissionService = require('~/server/services/PermissionService');
|
|
const { deleteUserMcpServers } = require('~/server/controllers/UserController');
|
|
const { AclEntry, AccessRole } = require('~/db/models');
|
|
|
|
let MCPServer;
|
|
|
|
describe('deleteUserMcpServers', () => {
|
|
let mongoServer;
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
const mongoUri = mongoServer.getUri();
|
|
MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema);
|
|
await mongoose.connect(mongoUri);
|
|
|
|
await AccessRole.create({
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
name: 'MCP Server Owner',
|
|
resourceType: ResourceType.MCPSERVER,
|
|
permBits:
|
|
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
|
});
|
|
|
|
await AccessRole.create({
|
|
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
|
name: 'MCP Server Viewer',
|
|
resourceType: ResourceType.MCPSERVER,
|
|
permBits: PermissionBits.VIEW,
|
|
});
|
|
}, 20000);
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await MCPServer.deleteMany({});
|
|
await AclEntry.deleteMany({});
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test('should delete solely-owned MCP servers and their ACL entries', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
const server = await MCPServer.create({
|
|
serverName: 'sole-owned-server',
|
|
config: { title: 'Test Server' },
|
|
author: userId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: server._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: userId,
|
|
});
|
|
|
|
mockGetMCPManager.mockReturnValue({
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
await deleteUserMcpServers(userId.toString());
|
|
|
|
expect(await MCPServer.findById(server._id)).toBeNull();
|
|
|
|
const aclEntries = await AclEntry.find({
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: server._id,
|
|
});
|
|
expect(aclEntries).toHaveLength(0);
|
|
});
|
|
|
|
test('should disconnect MCP sessions and invalidate tool cache before deletion', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
const mockDisconnect = jest.fn().mockResolvedValue(undefined);
|
|
|
|
const server = await MCPServer.create({
|
|
serverName: 'session-server',
|
|
config: { title: 'Session Server' },
|
|
author: userId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: server._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: userId,
|
|
});
|
|
|
|
mockGetMCPManager.mockReturnValue({ disconnectUserConnection: mockDisconnect });
|
|
|
|
await deleteUserMcpServers(userId.toString());
|
|
|
|
expect(mockDisconnect).toHaveBeenCalledWith(userId.toString(), 'session-server');
|
|
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
|
|
userId: userId.toString(),
|
|
serverName: 'session-server',
|
|
});
|
|
});
|
|
|
|
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();
|
|
|
|
const soleServer = await MCPServer.create({
|
|
serverName: 'sole-server',
|
|
config: { title: 'Sole Server' },
|
|
author: deletingUserId,
|
|
});
|
|
|
|
const multiServer = await MCPServer.create({
|
|
serverName: 'multi-server',
|
|
config: { title: 'Multi Server' },
|
|
author: deletingUserId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: deletingUserId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: soleServer._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: deletingUserId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: deletingUserId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: multiServer._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: deletingUserId,
|
|
});
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherOwnerId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: multiServer._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: otherOwnerId,
|
|
});
|
|
|
|
mockGetMCPManager.mockReturnValue({
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
await deleteUserMcpServers(deletingUserId.toString());
|
|
|
|
expect(await MCPServer.findById(soleServer._id)).toBeNull();
|
|
expect(await MCPServer.findById(multiServer._id)).not.toBeNull();
|
|
|
|
const soleAcl = await AclEntry.find({
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: soleServer._id,
|
|
});
|
|
expect(soleAcl).toHaveLength(0);
|
|
|
|
const multiAclOther = await AclEntry.find({
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: multiServer._id,
|
|
principalId: otherOwnerId,
|
|
});
|
|
expect(multiAclOther).toHaveLength(1);
|
|
expect(multiAclOther[0].permBits & PermissionBits.DELETE).toBeTruthy();
|
|
|
|
const multiAclDeleting = await AclEntry.find({
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: multiServer._id,
|
|
principalId: deletingUserId,
|
|
});
|
|
expect(multiAclDeleting).toHaveLength(1);
|
|
});
|
|
|
|
test('should be a no-op when user has no owned MCP servers', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
const otherUserId = new mongoose.Types.ObjectId();
|
|
const server = await MCPServer.create({
|
|
serverName: 'other-server',
|
|
config: { title: 'Other Server' },
|
|
author: otherUserId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: otherUserId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: server._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: otherUserId,
|
|
});
|
|
|
|
await deleteUserMcpServers(userId.toString());
|
|
|
|
expect(await MCPServer.findById(server._id)).not.toBeNull();
|
|
expect(mockGetMCPManager).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should handle gracefully when MCPServer model is not registered', async () => {
|
|
const originalModel = mongoose.models.MCPServer;
|
|
delete mongoose.models.MCPServer;
|
|
|
|
try {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
await expect(deleteUserMcpServers(userId.toString())).resolves.toBeUndefined();
|
|
} finally {
|
|
mongoose.models.MCPServer = originalModel;
|
|
}
|
|
});
|
|
|
|
test('should handle gracefully when MCPManager is not available', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
const server = await MCPServer.create({
|
|
serverName: 'no-manager-server',
|
|
config: { title: 'No Manager Server' },
|
|
author: userId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: server._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: userId,
|
|
});
|
|
|
|
mockGetMCPManager.mockReturnValue(null);
|
|
|
|
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 () => {
|
|
const legacyUserId = new mongoose.Types.ObjectId();
|
|
|
|
const legacyServer = await MCPServer.create({
|
|
serverName: 'legacy-server',
|
|
config: { title: 'Legacy Server' },
|
|
author: legacyUserId,
|
|
});
|
|
|
|
mockGetMCPManager.mockReturnValue({
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
await deleteUserMcpServers(legacyUserId.toString());
|
|
|
|
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
|
|
});
|
|
|
|
test('should delete both ACL-owned and legacy servers in one call', async () => {
|
|
const userId = new mongoose.Types.ObjectId();
|
|
|
|
const aclServer = await MCPServer.create({
|
|
serverName: 'acl-server',
|
|
config: { title: 'ACL Server' },
|
|
author: userId,
|
|
});
|
|
|
|
await permissionService.grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: aclServer._id,
|
|
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
|
grantedBy: userId,
|
|
});
|
|
|
|
const legacyServer = await MCPServer.create({
|
|
serverName: 'legacy-mixed-server',
|
|
config: { title: 'Legacy Mixed' },
|
|
author: userId,
|
|
});
|
|
|
|
mockGetMCPManager.mockReturnValue({
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
await deleteUserMcpServers(userId.toString());
|
|
|
|
expect(await MCPServer.findById(aclServer._id)).toBeNull();
|
|
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
|
|
});
|
|
});
|