🏗️ feat: Dynamic MCP Server Infrastructure with Access Control (#10787)

* Feature: Dynamic MCP Server with Full UI Management

* 🚦 feat: Add MCP Connection Status icons to MCPBuilder panel (#10805)

* feature: Add MCP server connection status icons to MCPBuilder panel

* refactor: Simplify MCPConfigDialog rendering in MCPBuilderPanel

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>

* fix: address code review feedback for MCP server management

- Fix OAuth secret preservation to avoid mutating input parameter
  by creating a merged config copy in ServerConfigsDB.update()

- Improve error handling in getResourcePermissionsMap to propagate
  critical errors instead of silently returning empty Map

- Extract duplicated MCP server filter logic by exposing selectableServers
  from useMCPServerManager hook and using it in MCPSelect component

* test: Update PermissionService tests to throw errors on invalid resource types

- Changed the test for handling invalid resource types to ensure it throws an error instead of returning an empty permissions map.
- Updated the expectation to check for the specific error message when an invalid resource type is provided.

* feat: Implement retry logic for MCP server creation to handle race conditions

- Enhanced the createMCPServer method to include retry logic with exponential backoff for handling duplicate key errors during concurrent server creation.
- Updated tests to verify that all concurrent requests succeed and that unique server names are generated.
- Added a helper function to identify MongoDB duplicate key errors, improving error handling during server creation.

* refactor: StatusIcon to use CircleCheck for connected status

- Replaced the PlugZap icon with CircleCheck in the ConnectedStatusIcon component to better represent the connected state.
- Ensured consistent icon usage across the component for improved visual clarity.

* test: Update AccessControlService tests to throw errors on invalid resource types

- Modified the test for invalid resource types to ensure it throws an error with a specific message instead of returning an empty permissions map.
- This change enhances error handling and improves test coverage for the AccessControlService.

* fix: Update error message for missing server name in MCP server retrieval

- Changed the error message returned when the server name is not provided from 'MCP ID is required' to 'Server name is required' for better clarity and accuracy in the API response.

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Atef Bellaaj 2025-12-04 21:37:23 +01:00 committed by Danny Avila
parent 41c0a96d39
commit 99f8bd2ce6
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
103 changed files with 7978 additions and 1003 deletions

View file

@ -15,6 +15,29 @@ const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry } = require('~/db/models');
const { getActions } = require('./Action');
/**
* Extracts unique MCP server names from tools array
* Tools format: "toolName_mcp_serverName" or "sys__server__sys_mcp_serverName"
* @param {string[]} tools - Array of tool identifiers
* @returns {string[]} Array of unique MCP server names
*/
const extractMCPServerNames = (tools) => {
if (!tools || !Array.isArray(tools)) {
return [];
}
const serverNames = new Set();
for (const tool of tools) {
if (!tool || !tool.includes(mcp_delimiter)) {
continue;
}
const parts = tool.split(mcp_delimiter);
if (parts.length >= 2) {
serverNames.add(parts[parts.length - 1]);
}
}
return Array.from(serverNames);
};
/**
* Create an agent with the provided data.
* @param {Object} agentData - The agent data to create.
@ -34,6 +57,7 @@ const createAgent = async (agentData) => {
},
],
category: agentData.category || 'general',
mcpServerNames: extractMCPServerNames(agentData.tools),
};
return (await Agent.create(initialAgentData)).toObject();
@ -354,6 +378,13 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
} = currentAgent.toObject();
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
// Sync mcpServerNames when tools are updated
if (directUpdates.tools !== undefined) {
const mcpServerNames = extractMCPServerNames(directUpdates.tools);
directUpdates.mcpServerNames = mcpServerNames;
updateData.mcpServerNames = mcpServerNames; // Also update the original updateData
}
let actionsHash = null;
// Generate actions hash if agent has actions

View file

@ -4,13 +4,15 @@
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType } = require('librechat-data-provider');
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
const {
bulkUpdateResourcePermissions,
ensureGroupPrincipalExists,
getEffectivePermissions,
ensurePrincipalExists,
getAvailableRoles,
findAccessibleResources,
getResourcePermissionsMap,
} = require('~/server/services/PermissionService');
const { AclEntry } = require('~/db/models');
const {
@ -475,10 +477,58 @@ const searchPrincipals = async (req, res) => {
}
};
/**
* Get user's effective permissions for all accessible resources of a type
* @route GET /api/permissions/{resourceType}/effective/all
*/
const getAllEffectivePermissions = async (req, res) => {
try {
const { resourceType } = req.params;
validateResourceType(resourceType);
const { id: userId } = req.user;
// Find all resources the user has at least VIEW access to
const accessibleResourceIds = await findAccessibleResources({
userId,
role: req.user.role,
resourceType,
requiredPermissions: PermissionBits.VIEW,
});
if (accessibleResourceIds.length === 0) {
return res.status(200).json({});
}
// Get effective permissions for all accessible resources
const permissionsMap = await getResourcePermissionsMap({
userId,
role: req.user.role,
resourceType,
resourceIds: accessibleResourceIds,
});
// Convert Map to plain object for JSON response
const result = {};
for (const [resourceId, permBits] of permissionsMap) {
result[resourceId] = permBits;
}
res.status(200).json(result);
} catch (error) {
logger.error('Error getting all effective permissions:', error);
res.status(500).json({
error: 'Failed to get all effective permissions',
details: error.message,
});
}
};
module.exports = {
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
getUserEffectivePermissions,
getAllEffectivePermissions,
searchPrincipals,
};

View file

@ -317,7 +317,7 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
const serverConfig =
(await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
appConfig?.mcpServers?.[serverName];
const oauthServers = await getMCPServersRegistry().getOAuthServers();
const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
if (!oauthServers.has(serverName)) {
// this server does not use OAuth, so nothing to do here as well
return;

View file

@ -1,9 +1,12 @@
/**
* MCP Tools Controller
* Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
*
* @import { MCPServerRegistry } from '@librechat/api'
* @import { MCPServerDocument } from 'librechat-data-provider'
*/
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
@ -133,7 +136,6 @@ const getMCPServersList = async (req, res) => {
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
// TODO - Ensure DB servers loaded into registry (configs only)
// 2. Get all server configs from registry (YAML + DB)
const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
@ -145,7 +147,113 @@ const getMCPServersList = async (req, res) => {
}
};
/**
* Create MCP server
* @route POST /api/mcp/servers
*/
const createMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { config } = req.body;
const validation = MCPServerUserInputSchema.safeParse(config);
if (!validation.success) {
return res.status(400).json({
message: 'Invalid configuration',
errors: validation.error.errors,
});
}
const result = await getMCPServersRegistry().addServer(
'temp_server_name',
validation.data,
'DB',
userId,
);
res.status(201).json({
serverName: result.serverName,
...result.config,
});
} catch (error) {
logger.error('[createMCPServer]', error);
res.status(500).json({ message: error.message });
}
};
/**
* Get MCP server by ID
*/
const getMCPServerById = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
if (!serverName) {
return res.status(400).json({ message: 'Server name is required' });
}
const parsedConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
if (!parsedConfig) {
return res.status(404).json({ message: 'MCP server not found' });
}
res.status(200).json(parsedConfig);
} catch (error) {
logger.error('[getMCPServerById]', error);
res.status(500).json({ message: error.message });
}
};
/**
* Update MCP server
* @route PATCH /api/mcp/servers/:serverName
*/
const updateMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
const { config } = req.body;
const validation = MCPServerUserInputSchema.safeParse(config);
if (!validation.success) {
return res.status(400).json({
message: 'Invalid configuration',
errors: validation.error.errors,
});
}
const parsedConfig = await getMCPServersRegistry().updateServer(
serverName,
validation.data,
'DB',
userId,
);
res.status(200).json(parsedConfig);
} catch (error) {
logger.error('[updateMCPServer]', error);
res.status(500).json({ message: error.message });
}
};
/**
* Delete MCP server
* @route DELETE /api/mcp/servers/:serverName
*/
const deleteMCPServerController = async (req, res) => {
try {
const userId = req.user?.id;
const { serverName } = req.params;
await getMCPServersRegistry().removeServer(serverName, 'DB', userId);
res.status(200).json({ message: 'MCP server deleted successfully' });
} catch (error) {
logger.error('[deleteMCPServer]', error);
res.status(500).json({ message: error.message });
}
};
module.exports = {
getMCPTools,
getMCPServersList,
createMCPServerController,
getMCPServerById,
updateMCPServerController,
deleteMCPServerController,
};

View file

@ -0,0 +1,61 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { findMCPServerById } = require('~/models');
/**
* MCP Server ID resolver function
* Resolves custom MCP server ID (e.g., "mcp_abc123") to MongoDB ObjectId
*
* @param {string} mcpServerCustomId - Custom MCP server ID from route parameter
* @returns {Promise<Object|null>} MCP server document with _id field, or null if not found
*/
const resolveMCPServerId = async (mcpServerCustomId) => {
return await findMCPServerById(mcpServerCustomId);
};
/**
* MCP Server-specific middleware factory that creates middleware to check MCP server access permissions.
* This middleware extends the generic canAccessResource to handle MCP server custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='serverName'] - The name of the route parameter containing the MCP server custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing MCP servers
* router.get('/servers/:serverName',
* canAccessMCPServerResource({ requiredPermission: 1 }),
* getMCPServer
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/servers/:id',
* canAccessMCPServerResource({
* requiredPermission: 2,
* resourceIdParam: 'id'
* }),
* updateMCPServer
* );
*/
const canAccessMCPServerResource = (options) => {
const { requiredPermission, resourceIdParam = 'serverName' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
}
return canAccessResource({
resourceType: ResourceType.MCPSERVER,
requiredPermission,
resourceIdParam,
idResolver: resolveMCPServerId,
});
};
module.exports = {
canAccessMCPServerResource,
};

View file

@ -0,0 +1,627 @@
const mongoose = require('mongoose');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createMCPServer } = require('~/models');
describe('canAccessMCPServerResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
MCPSERVERS: {
USE: true,
CREATE: true,
SHARED_GLOBAL: false,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id, role: testUser.role },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessMCPServerResource({})).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessMCPServerResource({ requiredPermission: '1' })).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is null', () => {
expect(() => canAccessMCPServerResource({ requiredPermission: null })).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam (serverName)', () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessMCPServerResource({
requiredPermission: 2,
resourceIdParam: 'mcpId',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real MCP servers', () => {
test('should allow access when user is the MCP server author', async () => {
// Create an MCP server owned by the test user
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Other User MCP Server',
},
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this mcpServer',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Shared MCP Server',
},
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Limited Access MCP Server',
},
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this mcpServer',
});
});
test('should handle non-existent MCP server', async () => {
req.params.serverName = 'non-existent-mcp-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'mcpServer not found',
});
});
test('should use custom resourceIdParam', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Custom Param MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.mcpId = mcpServer.serverName; // Using custom param name
const middleware = canAccessMCPServerResource({
requiredPermission: 1,
resourceIdParam: 'mcpId',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let mcpServer;
beforeEach(async () => {
mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Permission Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
});
test('should support view permission (1)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessMCPServerResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with resolveMCPServerId', () => {
test('should resolve serverName to MongoDB ObjectId correctly', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Integration Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
// Verify that req.resourceAccess was set correctly
expect(req.resourceAccess).toBeDefined();
expect(req.resourceAccess.resourceType).toBe(ResourceType.MCPSERVER);
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer._id.toString());
expect(req.resourceAccess.customResourceId).toBe(mcpServer.serverName);
});
test('should work with MCP server CRUD operations', async () => {
// Create MCP server
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'CRUD Test MCP Server',
description: 'Testing integration',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
// Test view access
const viewMiddleware = canAccessMCPServerResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the MCP server
const { updateMCPServer } = require('~/models');
await updateMCPServer(mcpServer.serverName, {
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'CRUD Test MCP Server',
description: 'Updated description',
},
});
// Test edit access
const editMiddleware = canAccessMCPServerResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle stdio type MCP server', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'stdio',
command: 'node',
args: ['server.js'],
title: 'Stdio MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15,
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceInfo.config.type).toBe('stdio');
});
});
describe('authentication and authorization edge cases', () => {
test('should return 400 when serverName parameter is missing', async () => {
// Don't set req.params.serverName
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'serverName is required',
});
});
test('should return 401 when user is not authenticated', async () => {
req.user = null;
req.params.serverName = 'some-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
test('should return 401 when user id is missing', async () => {
req.user = { role: 'test-role' }; // No id
req.params.serverName = 'some-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
test('should allow admin users to bypass permission checks', async () => {
const { SystemRoles } = require('librechat-data-provider');
// Create an MCP server owned by another user
const otherUser = await User.create({
email: 'owner@example.com',
name: 'Owner User',
username: 'owneruser',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Admin Test MCP Server',
},
author: otherUser._id,
});
// Set user as admin
req.user = { id: testUser._id, role: SystemRoles.ADMIN };
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 4 }); // DELETE permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
test('should handle server returning null gracefully (treated as not found)', async () => {
// When an MCP server is not found, findMCPServerById returns null
// which the middleware correctly handles as a 404
req.params.serverName = 'definitely-non-existent-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'mcpServer not found',
});
});
});
describe('multiple servers with same title', () => {
test('should handle MCP servers with auto-generated suffixes', async () => {
// Create multiple servers with the same title (will have different serverNames)
const mcpServer1 = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp1',
title: 'Duplicate Title',
},
author: testUser._id,
});
const mcpServer2 = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp2',
title: 'Duplicate Title',
},
author: testUser._id,
});
// Create ACL entries for both
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer1._id,
permBits: 15,
grantedBy: testUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer2._id,
permBits: 15,
grantedBy: testUser._id,
});
// Verify they have different serverNames
expect(mcpServer1.serverName).toBe('duplicate-title');
expect(mcpServer2.serverName).toBe('duplicate-title-2');
// Test access to first server
req.params.serverName = mcpServer1.serverName;
const middleware1 = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware1(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer1._id.toString());
jest.clearAllMocks();
// Test access to second server
req.params.serverName = mcpServer2.serverName;
const middleware2 = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware2(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer2._id.toString());
});
});
});

View file

@ -3,6 +3,7 @@ const { canAccessAgentResource } = require('./canAccessAgentResource');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
module.exports = {
canAccessResource,
@ -10,4 +11,5 @@ module.exports = {
canAccessAgentFromBody,
canAccessPromptViaGroup,
canAccessPromptGroupResource,
canAccessMCPServerResource,
};

View file

@ -7,6 +7,9 @@ const mockRegistryInstance = {
getServerConfig: jest.fn(),
getOAuthServers: jest.fn(),
getAllServerConfigs: jest.fn(),
addServer: jest.fn(),
updateServer: jest.fn(),
removeServer: jest.fn(),
};
jest.mock('@librechat/api', () => ({
@ -24,6 +27,7 @@ jest.mock('@librechat/api', () => ({
deleteUserTokens: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
generateCheckAccess: jest.fn(() => (req, res, next) => next()),
MCPServersRegistry: {
getInstance: () => mockRegistryInstance,
},
@ -57,6 +61,7 @@ jest.mock('~/models', () => ({
createToken: jest.fn(),
deleteTokens: jest.fn(),
findPluginAuthsByKeys: jest.fn(),
getRoleByName: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
@ -92,6 +97,7 @@ jest.mock('~/cache', () => ({
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
canAccessMCPServerResource: () => (req, res, next) => next(),
}));
jest.mock('~/server/services/Tools/mcp', () => ({
@ -1488,4 +1494,224 @@ describe('MCP Routes', () => {
expect(response.body).toEqual({ error: 'Database error' });
});
});
describe('POST /servers', () => {
it('should create MCP server with valid SSE config', async () => {
const validConfig = {
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Test SSE Server',
description: 'A test SSE server',
};
mockRegistryInstance.addServer.mockResolvedValue({
serverName: 'test-sse-server',
config: validConfig,
});
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(201);
expect(response.body).toEqual({
serverName: 'test-sse-server',
...validConfig,
});
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
'temp_server_name',
expect.objectContaining({
type: 'sse',
url: 'https://mcp-server.example.com/sse',
}),
'DB',
'test-user-id',
);
});
it('should create MCP server with valid stdio config', async () => {
const validConfig = {
type: 'stdio',
command: 'node',
args: ['server.js'],
title: 'Test Stdio Server',
};
mockRegistryInstance.addServer.mockResolvedValue({
serverName: 'test-stdio-server',
config: validConfig,
});
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(201);
expect(response.body.serverName).toBe('test-stdio-server');
});
it('should return 400 for invalid configuration', async () => {
const invalidConfig = {
type: 'sse',
// Missing required 'url' field
title: 'Invalid Server',
};
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(response.body.errors).toBeDefined();
});
it('should return 400 for SSE config with invalid URL protocol', async () => {
const invalidConfig = {
type: 'sse',
url: 'ws://invalid-protocol.example.com/sse',
title: 'Invalid Protocol Server',
};
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
});
it('should return 500 when registry throws error', async () => {
const validConfig = {
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Test Server',
};
mockRegistryInstance.addServer.mockRejectedValue(new Error('Database connection failed'));
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Database connection failed' });
});
});
describe('GET /servers/:serverName', () => {
it('should return server config when found', async () => {
const mockConfig = {
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Test Server',
};
mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig);
const response = await request(app).get('/api/mcp/servers/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockConfig);
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
'test-server',
'test-user-id',
);
});
it('should return 404 when server not found', async () => {
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
const response = await request(app).get('/api/mcp/servers/non-existent-server');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'MCP server not found' });
});
it('should return 500 when registry throws error', async () => {
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/servers/error-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Database error' });
});
});
describe('PATCH /servers/:serverName', () => {
it('should update server with valid config', async () => {
const updatedConfig = {
type: 'sse',
url: 'https://updated-mcp-server.example.com/sse',
title: 'Updated Server',
description: 'Updated description',
};
mockRegistryInstance.updateServer.mockResolvedValue(updatedConfig);
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({ config: updatedConfig });
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedConfig);
expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
'test-server',
expect.objectContaining({
type: 'sse',
url: 'https://updated-mcp-server.example.com/sse',
}),
'DB',
'test-user-id',
);
});
it('should return 400 for invalid configuration', async () => {
const invalidConfig = {
type: 'sse',
// Missing required 'url' field
title: 'Invalid Update',
};
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({ config: invalidConfig });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid configuration');
expect(response.body.errors).toBeDefined();
});
it('should return 500 when registry throws error', async () => {
const validConfig = {
type: 'sse',
url: 'https://mcp-server.example.com/sse',
title: 'Test Server',
};
mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
const response = await request(app)
.patch('/api/mcp/servers/test-server')
.send({ config: validConfig });
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Update failed' });
});
});
describe('DELETE /servers/:serverName', () => {
it('should delete server successfully', async () => {
mockRegistryInstance.removeServer.mockResolvedValue(undefined);
const response = await request(app).delete('/api/mcp/servers/test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'MCP server deleted successfully' });
expect(mockRegistryInstance.removeServer).toHaveBeenCalledWith(
'test-server',
'DB',
'test-user-id',
);
});
it('should return 500 when registry throws error', async () => {
mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
const response = await request(app).delete('/api/mcp/servers/error-server');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Deletion failed' });
});
});
});

View file

@ -2,6 +2,7 @@ const express = require('express');
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const {
getUserEffectivePermissions,
getAllEffectivePermissions,
updateResourcePermissions,
getResourcePermissions,
getResourceRoles,
@ -9,6 +10,7 @@ const {
} = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
const { findMCPServerById } = require('~/models');
const router = express.Router();
@ -63,6 +65,13 @@ router.put(
requiredPermission: PermissionBits.SHARE,
resourceIdParam: 'resourceId',
});
} else if (resourceType === ResourceType.MCPSERVER) {
middleware = canAccessResource({
resourceType: ResourceType.MCPSERVER,
requiredPermission: PermissionBits.SHARE,
resourceIdParam: 'resourceId',
idResolver: findMCPServerById,
});
} else {
return res.status(400).json({
error: 'Bad Request',
@ -76,6 +85,12 @@ router.put(
updateResourcePermissions,
);
/**
* GET /api/permissions/{resourceType}/effective/all
* Get user's effective permissions for all accessible resources of a type
*/
router.get('/:resourceType/effective/all', getAllEffectivePermissions);
/**
* GET /api/permissions/{resourceType}/{resourceId}/effective
* Get user's effective permissions for a specific resource

View file

@ -1,11 +1,18 @@
const { Router } = require('express');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const {
CacheKeys,
Constants,
PermissionBits,
PermissionTypes,
Permissions,
} = require('librechat-data-provider');
const {
createSafeUser,
MCPOAuthHandler,
MCPTokenStorage,
getUserMCPAuthMap,
generateCheckAccess,
} = require('@librechat/api');
const {
getMCPManager,
@ -14,15 +21,22 @@ const {
getMCPServersRegistry,
} = require('~/config');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { getMCPServersList } = require('~/server/controllers/mcp');
const { getMCPTools } = require('~/server/controllers/mcp');
const { requireJwtAuth } = require('~/server/middleware');
const { findPluginAuthsByKeys } = require('~/models');
const { getRoleByName } = require('~/models/Role');
const { getLogStores } = require('~/cache');
const {
createMCPServerController,
getMCPServerById,
getMCPServersList,
updateMCPServerController,
deleteMCPServerController,
} = require('~/server/controllers/mcp');
const router = Router();
@ -573,11 +587,93 @@ async function getOAuthHeaders(serverName, userId) {
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
return serverConfig?.oauth_headers ?? {};
}
/**
MCP Server CRUD Routes (User-Managed MCP Servers)
*/
// Permission checkers for MCP server management
const checkMCPUsePermissions = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE],
getRoleByName,
});
const checkMCPCreate = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
/**
* Get list of accessible MCP servers
* @route GET /api/mcp/servers
* @returns {MCPServersListResponse} 200 - Success response - application/json
* @param {Object} req.query - Query parameters for pagination and search
* @param {number} [req.query.limit] - Number of results per page
* @param {string} [req.query.after] - Pagination cursor
* @param {string} [req.query.search] - Search query for title/description
* @returns {MCPServerListResponse} 200 - Success response - application/json
*/
router.get('/servers', requireJwtAuth, getMCPServersList);
router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList);
/**
* Create a new MCP server
* @route POST /api/mcp/servers
* @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
* @returns {MCPServer} 201 - Success response - application/json
*/
router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerController);
/**
* Get single MCP server by ID
* @route GET /api/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @returns {MCPServer} 200 - Success response - application/json
*/
router.get(
'/servers/:serverName',
requireJwtAuth,
checkMCPUsePermissions,
canAccessMCPServerResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'serverName',
}),
getMCPServerById,
);
/**
* Update MCP server
* @route PATCH /api/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
* @returns {MCPServer} 200 - Success response - application/json
*/
router.patch(
'/servers/:serverName',
requireJwtAuth,
checkMCPCreate,
canAccessMCPServerResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'serverName',
}),
updateMCPServerController,
);
/**
* Delete MCP server
* @route DELETE /api/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @returns {Object} 200 - Success response - application/json
*/
router.delete(
'/servers/:serverName',
requireJwtAuth,
checkMCPCreate,
canAccessMCPServerResource({
requiredPermission: PermissionBits.DELETE,
resourceIdParam: 'serverName',
}),
deleteMCPServerController,
);
module.exports = router;

View file

@ -8,6 +8,7 @@ const {
memoryPermissionsSchema,
marketplacePermissionsSchema,
peoplePickerPermissionsSchema,
mcpServersPermissionsSchema,
} = require('librechat-data-provider');
const { checkAdmin, requireJwtAuth } = require('~/server/middleware');
const { updateRoleByName, getRoleByName } = require('~/models/Role');
@ -40,6 +41,11 @@ const permissionConfigs = {
permissionType: PermissionTypes.PEOPLE_PICKER,
errorMessage: 'Invalid people picker permissions.',
},
'mcp-servers': {
schema: mcpServersPermissionsSchema,
permissionType: PermissionTypes.MCP_SERVERS,
errorMessage: 'Invalid MCP servers permissions.',
},
marketplace: {
schema: marketplacePermissionsSchema,
permissionType: PermissionTypes.MARKETPLACE,
@ -142,6 +148,12 @@ router.put('/:roleName/memories', checkAdmin, createPermissionUpdateHandler('mem
*/
router.put('/:roleName/people-picker', checkAdmin, createPermissionUpdateHandler('people-picker'));
/**
* PUT /api/roles/:roleName/mcp-servers
* Update MCP servers permissions for a specific role
*/
router.put('/:roleName/mcp-servers', checkAdmin, createPermissionUpdateHandler('mcp-servers'));
/**
* PUT /api/roles/:roleName/marketplace
* Update marketplace permissions for a specific role

View file

@ -541,7 +541,7 @@ async function getServerConnectionStatus(
oauthServers,
) {
const connection = appConnections.get(serverName) || userConnections.get(serverName);
const isStaleOrDoNotExist = connection ? connection?.isStale(config.lastUpdatedAt) : true;
const isStaleOrDoNotExist = connection ? connection?.isStale(config.updatedAt) : true;
const baseConnectionState = isStaleOrDoNotExist
? 'disconnected'

View file

@ -330,7 +330,7 @@ describe('tests for the new helper functions used by the MCP connection status e
describe('getServerConnectionStatus', () => {
const mockUserId = 'user-123';
const mockServerName = 'test-server';
const mockConfig = { lastUpdatedAt: Date.now() };
const mockConfig = { updatedAt: Date.now() };
it('should return app connection state when available', async () => {
const appConnections = new Map([

View file

@ -12,6 +12,7 @@ const {
const {
findAccessibleResources: findAccessibleResourcesACL,
getEffectivePermissions: getEffectivePermissionsACL,
getEffectivePermissionsForResources: getEffectivePermissionsForResourcesACL,
grantPermission: grantPermissionACL,
findEntriesByPrincipalsAndResource,
findGroupByExternalId,
@ -184,6 +185,49 @@ const getEffectivePermissions = async ({ userId, role, resourceType, resourceId
}
};
/**
* Get effective permissions for multiple resources in a batch operation
* Returns map of resourceId effectivePermissionBits
*
* @param {Object} params - Parameters
* @param {string|mongoose.Types.ObjectId} params.userId - User ID
* @param {string} [params.role] - User role (for group membership)
* @param {string} params.resourceType - Resource type (must be valid ResourceType)
* @param {Array<mongoose.Types.ObjectId>} params.resourceIds - Array of resource IDs
* @returns {Promise<Map<string, number>>} Map of resourceId string permission bits
* @throws {Error} If resourceType is invalid
*/
const getResourcePermissionsMap = async ({ userId, role, resourceType, resourceIds }) => {
// Validate resource type - throw on invalid type
validateResourceType(resourceType);
// Handle empty input
if (!Array.isArray(resourceIds) || resourceIds.length === 0) {
return new Map();
}
try {
// Get user principals (user + groups + public)
const principals = await getUserPrincipals({ userId, role });
// Use batch method from aclEntry
const permissionsMap = await getEffectivePermissionsForResourcesACL(
principals,
resourceType,
resourceIds,
);
logger.debug(
`[PermissionService.getResourcePermissionsMap] Computed permissions for ${resourceIds.length} resources, ${permissionsMap.size} have permissions`,
);
return permissionsMap;
} catch (error) {
logger.error(`[PermissionService.getResourcePermissionsMap] Error: ${error.message}`, error);
throw error;
}
};
/**
* Find all resources of a specific type that a user has access to with specific permission bits
* @param {Object} params - Parameters for finding accessible resources
@ -788,6 +832,7 @@ module.exports = {
grantPermission,
checkPermission,
getEffectivePermissions,
getResourcePermissionsMap,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,

View file

@ -1604,4 +1604,332 @@ describe('PermissionService', () => {
expect(effectivePermissions).toBe(3); // EDITOR includes VIEW
});
});
describe('getResourcePermissionsMap - Batch Permission Queries', () => {
const { getResourcePermissionsMap } = require('./PermissionService');
beforeEach(async () => {
await AclEntry.deleteMany({});
getUserPrincipals.mockReset();
});
test('should get permissions for multiple resources in single query', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
const resource3 = new mongoose.Types.ObjectId();
// Grant different permissions to different resources
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
// resource3 has no permissions
// Mock getUserPrincipals
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2, resource3],
});
expect(permissionsMap).toBeInstanceOf(Map);
expect(permissionsMap.size).toBe(2); // Only resource1 and resource2
expect(permissionsMap.get(resource1.toString())).toBe(1); // VIEW
expect(permissionsMap.get(resource2.toString())).toBe(3); // VIEW | EDIT
expect(permissionsMap.get(resource3.toString())).toBeUndefined();
});
test('should combine permissions from multiple principals', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
// User has VIEW on both resources
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
// Group has EDIT on resource1
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
// Mock getUserPrincipals with user + group
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2],
});
expect(permissionsMap.size).toBe(2);
// Resource1 should have VIEW (1) | EDIT (3) = 3
expect(permissionsMap.get(resource1.toString())).toBe(3);
// Resource2 should have only VIEW (1)
expect(permissionsMap.get(resource2.toString())).toBe(1);
});
test('should handle empty resource list', async () => {
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [],
});
expect(permissionsMap).toBeInstanceOf(Map);
expect(permissionsMap.size).toBe(0);
});
test('should throw on invalid resource type', async () => {
const resource1 = new mongoose.Types.ObjectId();
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
// Validation errors should throw immediately
await expect(
getResourcePermissionsMap({
userId,
resourceType: 'invalid_type',
resourceIds: [resource1],
}),
).rejects.toThrow('Invalid resourceType: invalid_type');
});
test('should include public permissions in batch query', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
// User has VIEW | EDIT on resource1
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
// Public has VIEW on resource2
await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
// Mock getUserPrincipals with user + public
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2],
});
expect(permissionsMap.size).toBe(2);
expect(permissionsMap.get(resource1.toString())).toBe(3); // VIEW | EDIT
expect(permissionsMap.get(resource2.toString())).toBe(1); // VIEW (public)
});
test('should handle large batch efficiently', async () => {
// Create 50 resources
const resources = Array.from({ length: 50 }, () => new mongoose.Types.ObjectId());
// Grant permissions to first 30 resources
for (let i = 0; i < 30; i++) {
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resources[i],
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
}
// Grant group permissions to resources 20-40 (overlap)
for (let i = 20; i < 40; i++) {
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.MCPSERVER,
resourceId: resources[i],
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
}
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
]);
const startTime = Date.now();
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: resources,
});
const duration = Date.now() - startTime;
// Should complete in reasonable time (under 1 second)
expect(duration).toBeLessThan(1000);
// Verify results
expect(permissionsMap.size).toBe(40); // Resources 0-39 have permissions
// Resources 0-19: USER VIEW only
for (let i = 0; i < 20; i++) {
expect(permissionsMap.get(resources[i].toString())).toBe(1); // VIEW
}
// Resources 20-29: USER VIEW | GROUP EDIT = 3
for (let i = 20; i < 30; i++) {
expect(permissionsMap.get(resources[i].toString())).toBe(3); // VIEW | EDIT
}
// Resources 30-39: GROUP EDIT = 3
for (let i = 30; i < 40; i++) {
expect(permissionsMap.get(resources[i].toString())).toBe(3); // EDIT includes VIEW
}
// Resources 40-49: No permissions
for (let i = 40; i < 50; i++) {
expect(permissionsMap.get(resources[i].toString())).toBeUndefined();
}
});
test('should work with role parameter optimization', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
// Grant permissions to ADMIN role
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'ADMIN',
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'ADMIN',
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.ROLE, principalId: 'ADMIN' },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
role: 'ADMIN',
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2],
});
expect(permissionsMap.size).toBe(2);
expect(permissionsMap.get(resource1.toString())).toBe(15); // OWNER = all bits
expect(permissionsMap.get(resource2.toString())).toBe(3); // EDIT
expect(getUserPrincipals).toHaveBeenCalledWith({ userId, role: 'ADMIN' });
});
test('should handle mixed ObjectId and string resource IDs', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
// Pass mix of ObjectId and string
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2.toString()],
});
expect(permissionsMap.size).toBe(2);
expect(permissionsMap.get(resource1.toString())).toBe(1);
expect(permissionsMap.get(resource2.toString())).toBe(3);
});
});
});