mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🏗️ 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:
parent
41c0a96d39
commit
99f8bd2ce6
103 changed files with 7978 additions and 1003 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue