diff --git a/api/models/Agent.js b/api/models/Agent.js index 1cd6ba3ed9..5f171ef1f2 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -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 diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index f44aebae7b..e22e9532c9 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -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, }; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index e95cdc36a0..b0cfd7ede2 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -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; diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index 591816cd14..a629dc9ce7 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -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, }; diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.js b/api/server/middleware/accessResources/canAccessMCPServerResource.js new file mode 100644 index 0000000000..69f5f4e4f6 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessMCPServerResource.js @@ -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} 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, +}; diff --git a/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js new file mode 100644 index 0000000000..5eef1438ff --- /dev/null +++ b/api/server/middleware/accessResources/canAccessMCPServerResource.spec.js @@ -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()); + }); + }); +}); diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js index e1c5def829..838834919a 100644 --- a/api/server/middleware/accessResources/index.js +++ b/api/server/middleware/accessResources/index.js @@ -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, }; diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 4a53581d4c..af038ba8d6 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -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' }); + }); + }); }); diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 532f3bc50c..7431a86f1e 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -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 diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 62d735a797..0cee7f991a 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -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; diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index f197417774..abb53141bd 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -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 diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index d0568fd189..57fa07110a 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -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' diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 2f6d1603a9..7f68c3210c 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -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([ diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 58afffec4a..c35faf7c8d 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -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} params.resourceIds - Array of resource IDs + * @returns {Promise>} 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, diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js index 5772f3b909..b41780f345 100644 --- a/api/server/services/PermissionService.spec.js +++ b/api/server/services/PermissionService.spec.js @@ -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); + }); + }); }); diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index cb0fbe9dc9..b0d74374b4 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -66,10 +66,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) if (mcpData?.servers) { for (const [serverName, serverData] of Object.entries(mcpData.servers)) { + // Get title and description from config with fallbacks + const serverConfig = availableMCPServersMap?.[serverName]; + const displayName = serverConfig?.title || serverName; + const displayDescription = + serverConfig?.description || `${localize('com_ui_tool_collection_prefix')} ${serverName}`; + const metadata = { - name: serverName, + name: displayName, pluginKey: serverName, - description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, + description: displayDescription, icon: serverData.icon || '', authConfig: serverData.authConfig, authenticated: serverData.authenticated, @@ -91,6 +97,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) isConfigured: configuredServers.has(serverName), isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', metadata, + consumeOnly: serverConfig?.consumeOnly, }); } } @@ -100,11 +107,18 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) if (serversMap.has(mcpServerName)) { continue; } + // Get title and description from config with fallbacks + const serverConfig = availableMCPServersMap?.[mcpServerName]; + const displayName = serverConfig?.title || mcpServerName; + const displayDescription = + serverConfig?.description || + `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`; + const metadata = { - icon: '', - name: mcpServerName, + icon: serverConfig?.iconPath || '', + name: displayName, pluginKey: mcpServerName, - description: `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`, + description: displayDescription, } as TPlugin; serversMap.set(mcpServerName, { @@ -113,11 +127,12 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) isConfigured: true, serverName: mcpServerName, isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected', + consumeOnly: serverConfig?.consumeOnly, }); } return serversMap; - }, [mcpData, localize, mcpServerNames, connectionStatus]); + }, [mcpData, localize, mcpServerNames, connectionStatus, availableMCPServersMap]); const value: AgentPanelContextType = { mcp, diff --git a/client/src/Providers/MCPPanelContext.tsx b/client/src/Providers/MCPPanelContext.tsx deleted file mode 100644 index 948be0ad65..0000000000 --- a/client/src/Providers/MCPPanelContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { createContext, useContext, useMemo } from 'react'; -import { Constants } from 'librechat-data-provider'; -import { useChatContext } from './ChatContext'; - -interface MCPPanelContextValue { - conversationId: string; -} - -const MCPPanelContext = createContext(undefined); - -export function MCPPanelProvider({ children }: { children: React.ReactNode }) { - const { conversation } = useChatContext(); - - /** Context value only created when conversationId changes */ - const contextValue = useMemo( - () => ({ - conversationId: conversation?.conversationId ?? Constants.NEW_CONVO, - }), - [conversation?.conversationId], - ); - - return {children}; -} - -export function useMCPPanelContext() { - const context = useContext(MCPPanelContext); - if (!context) { - throw new Error('useMCPPanelContext must be used within MCPPanelProvider'); - } - return context; -} diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index d3607d291d..43a16fa976 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -24,7 +24,6 @@ export * from './SearchContext'; export * from './BadgeRowContext'; export * from './SidePanelContext'; export * from './DragDropContext'; -export * from './MCPPanelContext'; export * from './ArtifactsContext'; export * from './PromptGroupsContext'; export * from './MessagesViewContext'; diff --git a/client/src/common/mcp.ts b/client/src/common/mcp.ts deleted file mode 100644 index b4f44a1f94..0000000000 --- a/client/src/common/mcp.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - AuthorizationTypeEnum, - AuthTypeEnum, - TokenExchangeMethodEnum, -} from 'librechat-data-provider'; -import { MCPForm } from '~/common/types'; - -export const defaultMCPFormValues: MCPForm = { - type: AuthTypeEnum.None, - saved_auth_fields: false, - api_key: '', - authorization_type: AuthorizationTypeEnum.Basic, - custom_auth_header: '', - oauth_client_id: '', - oauth_client_secret: '', - authorization_url: '', - client_url: '', - scope: '', - token_exchange_method: TokenExchangeMethodEnum.DefaultPost, - name: '', - description: '', - url: '', - tools: [], - icon: '', - trust: false, -}; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 1c2ff06c9e..ba3d4d34a3 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -153,7 +153,6 @@ export enum Panel { actions = 'actions', model = 'model', version = 'version', - mcp = 'mcp', } export type FileSetter = @@ -177,15 +176,6 @@ export type ActionAuthForm = { token_exchange_method: t.TokenExchangeMethodEnum; }; -export type MCPForm = ActionAuthForm & { - name?: string; - description?: string; - url?: string; - tools?: string[]; - icon?: string; - trust?: boolean; -}; - export type ActionWithNullableMetadata = Omit & { metadata: t.ActionMetadata | null; }; @@ -226,6 +216,7 @@ export interface MCPServerInfo { tools: t.AgentToolType[]; isConfigured: boolean; isConnected: boolean; + consumeOnly?: boolean; metadata: t.TPlugin; } diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index f634d3b11e..12b6bd627e 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -1,8 +1,10 @@ import React, { memo, useCallback } from 'react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { MultiSelect, MCPIcon } from '@librechat/client'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import { useBadgeRowContext } from '~/Providers'; +import { useHasAccess } from '~/hooks'; function MCPSelectContent() { const { conversationId, mcpServerManager } = useBadgeRowContext(); @@ -15,16 +17,21 @@ function MCPSelectContent() { batchToggleServers, getConfigDialogProps, getServerStatusIconProps, - availableMCPServers, + selectableServers, } = mcpServerManager; const renderSelectedValues = useCallback( - (values: string[], placeholder?: string) => { + ( + values: string[], + placeholder?: string, + items?: (string | { label: string; value: string })[], + ) => { if (values.length === 0) { return placeholder || localize('com_ui_select_placeholder'); } if (values.length === 1) { - return values[0]; + const selectedItem = items?.find((i) => typeof i !== 'string' && i.value == values[0]); + return selectedItem && typeof selectedItem !== 'string' ? selectedItem.label : values[0]; } return localize('com_ui_x_selected', { 0: values.length }); }, @@ -74,11 +81,13 @@ function MCPSelectContent() { } const configDialogProps = getConfigDialogProps(); - return ( <> s.serverName)} + items={selectableServers.map((s) => ({ + label: s.config.title || s.serverName, + value: s.serverName, + }))} selectedValues={mcpValues ?? []} setSelectedValues={batchToggleServers} renderSelectedValues={renderSelectedValues} @@ -99,9 +108,13 @@ function MCPSelectContent() { function MCPSelect() { const { mcpServerManager } = useBadgeRowContext(); - const { availableMCPServers } = mcpServerManager; + const { selectableServers } = mcpServerManager; + const canUseMcp = useHasAccess({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.USE, + }); - if (!availableMCPServers || availableMCPServers.length === 0) { + if (!canUseMcp || !selectableServers || selectableServers.length === 0) { return null; } diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index 5c07785c9c..fc4152be5c 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -112,7 +112,7 @@ const MCPSubMenu = React.forwardRef( >
- {s.serverName} + {s.config.title || s.serverName}
{statusIcon &&
{statusIcon}
} diff --git a/client/src/components/Chat/Input/ToolsDropdown.tsx b/client/src/components/Chat/Input/ToolsDropdown.tsx index 43117c9a03..1acb381b2d 100644 --- a/client/src/components/Chat/Input/ToolsDropdown.tsx +++ b/client/src/components/Chat/Input/ToolsDropdown.tsx @@ -72,6 +72,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { permission: Permissions.USE, }); + const canUseMcp = useHasAccess({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.USE, + }); + const showWebSearchSettings = useMemo(() => { const authTypes = webSearchAuthData?.authTypes ?? []; if (authTypes.length === 0) return true; @@ -286,8 +291,8 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { }); } - const { configuredServers } = mcpServerManager; - if (configuredServers && configuredServers.length > 0) { + const { availableMCPServers } = mcpServerManager; + if (canUseMcp && availableMCPServers && availableMCPServers.length > 0) { dropdownItems.push({ hideOnClick: false, render: (props) => , diff --git a/client/src/components/MCP/MCPServerStatusIcon.tsx b/client/src/components/MCP/MCPServerStatusIcon.tsx index cebd15368d..36a876e23e 100644 --- a/client/src/components/MCP/MCPServerStatusIcon.tsx +++ b/client/src/components/MCP/MCPServerStatusIcon.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Spinner } from '@librechat/client'; -import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X } from 'lucide-react'; +import { Spinner, TooltipAnchor } from '@librechat/client'; +import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X, CircleCheck } from 'lucide-react'; import type { MCPServerStatus, TPlugin } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; @@ -85,7 +85,13 @@ export default function MCPServerStatusIcon({ /> ); } - return null; // No config button for connected servers without customUserVars + return ( + + ); } return null; @@ -192,3 +198,36 @@ function AuthenticatedStatusIcon({ ); } + +interface ConnectedStatusProps { + serverName: string; + requiresOAuth?: boolean; + onConfigClick: (e: React.MouseEvent) => void; +} + +function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) { + if (requiresOAuth) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/client/src/components/Sharing/PublicSharingToggle.tsx b/client/src/components/Sharing/PublicSharingToggle.tsx index 393c7e6f85..3f68d4fdf6 100644 --- a/client/src/components/Sharing/PublicSharingToggle.tsx +++ b/client/src/components/Sharing/PublicSharingToggle.tsx @@ -16,9 +16,13 @@ interface PublicSharingToggleProps { className?: string; } -const accessDescriptions: Record = { +const accessDescriptions: Record< + ResourceType, + 'com_ui_agent' | 'com_ui_prompt' | 'com_ui_mcp_server' +> = { [ResourceType.AGENT]: 'com_ui_agent', [ResourceType.PROMPTGROUP]: 'com_ui_prompt', + [ResourceType.MCPSERVER]: 'com_ui_mcp_server', }; export default function PublicSharingToggle({ diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 940607549c..2e247a00f0 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -312,6 +312,7 @@ export default function AgentConfig() { setShowMCPToolDialog={setShowMCPToolDialog} /> )} + {/* Agent Tools & Actions */}
+ } + selection={{ + selectHandler: handleDelete, + selectClasses: + 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', + selectText: isDeleting ? : localize('com_ui_delete'), + }} + /> + + + {/* Post-creation redirect URI dialog */} + { + setShowRedirectUriDialog(open); + if (!open) { + onOpenChange(false); + setCreatedServerId(null); + } + }} + > + + + {localize('com_ui_mcp_server_created')} + +
+

+ {localize('com_ui_redirect_uri_instructions')} +

+
+ +
+ + +
+
+
+ +
+
+
+
+ + {/* Main MCP Server Dialog */} + + {children} + +
+ {/* Icon Picker */} +
+ +
+ + {/* Title */} +
+ + + {errors.title && ( + + {errors.title.type === 'pattern' + ? errors.title.message + : localize('com_ui_field_required')} + + )} +
+ + {/* Description */} +
+ + +
+ + {/* URL */} +
+ + + {errors.url && ( + + {errors.url.type === 'required' + ? localize('com_ui_field_required') + : errors.url.message} + + )} +
+ + {/* Server Type */} +
+ + ( + +
+ +
+
+ +
+
+ )} + /> +
+ + {/* Authentication */} + } + /> + + {/* Trust Checkbox */} +
+ ( + + )} + /> + +
+ {errors.trust && ( + {localize('com_ui_field_required')} + )} +
+ + } + footerClassName="sm:justify-between" + leftButtons={ + server ? ( +
+ + {shouldShowShareButton && ( + + )} +
+ ) : null + } + buttons={ + + } + /> +
+ + ); +} diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerList.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerList.tsx new file mode 100644 index 0000000000..5b36c8e4d2 --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerList.tsx @@ -0,0 +1,91 @@ +import { useState, useRef } from 'react'; +import { GearIcon, MCPIcon, OGDialogTrigger } from '@librechat/client'; +import { + PermissionBits, + PermissionTypes, + Permissions, + hasPermissions, +} from 'librechat-data-provider'; +import { useLocalize, useHasAccess, MCPServerDefinition } from '~/hooks'; +import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; +import MCPServerDialog from './MCPServerDialog'; + +interface MCPServerListProps { + servers: MCPServerDefinition[]; + getServerStatusIconProps: ( + serverName: string, + ) => React.ComponentProps; +} + +// Self-contained edit button component (follows MemoryViewer pattern) +const EditMCPServerButton = ({ server }: { server: MCPServerDefinition }) => { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + return ( + + + + + + ); +}; + +export default function MCPServerList({ servers, getServerStatusIconProps }: MCPServerListProps) { + const canCreateEditMCPs = useHasAccess({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.CREATE, + }); + const localize = useLocalize(); + + if (servers.length === 0) { + return ( +
+

{localize('com_ui_no_mcp_servers')}

+

{localize('com_ui_add_first_mcp_server')}

+
+ ); + } + + return ( +
+ {servers.map((server) => { + const canEditThisServer = hasPermissions(server.effectivePermissions, PermissionBits.EDIT); + const displayName = server.config?.title || server.serverName; + const serverKey = `key_${server.serverName}`; + + return ( +
+
+ {/* Server Icon */} + {server.config?.iconPath ? ( + {displayName} + ) : ( + + )} + + {/* Server Info */} +
+

{displayName}

+
+ + {/* Edit Button - Only for DB servers and when user has CREATE access */} + {canCreateEditMCPs && canEditThisServer && } + + {/* Connection Status Icon */} + +
+
+ ); + })} +
+ ); +} diff --git a/client/src/components/SidePanel/MCPBuilder/index.ts b/client/src/components/SidePanel/MCPBuilder/index.ts new file mode 100644 index 0000000000..33cdbd03ec --- /dev/null +++ b/client/src/components/SidePanel/MCPBuilder/index.ts @@ -0,0 +1,5 @@ +export { default } from './MCPBuilderPanel'; +export { default as MCPBuilderPanel } from './MCPBuilderPanel'; +export { default as MCPServerList } from './MCPServerList'; +export { default as MCPServerDialog } from './MCPServerDialog'; +export { default as MCPAuth } from './MCPAuth'; diff --git a/client/src/components/Tools/MCPToolSelectDialog.tsx b/client/src/components/Tools/MCPToolSelectDialog.tsx index 1b6936a959..487f767250 100644 --- a/client/src/components/Tools/MCPToolSelectDialog.tsx +++ b/client/src/components/Tools/MCPToolSelectDialog.tsx @@ -207,7 +207,7 @@ function MCPToolSelectDialog({ }, [mcpServerNames]); const mcpServers = useMemo(() => { - const servers = Array.from(mcpServersMap.values()); + const servers = Array.from(mcpServersMap.values()).filter((s) => !s.consumeOnly); return servers.sort((a, b) => a.serverName.localeCompare(b.serverName)); }, [mcpServersMap]); @@ -340,7 +340,6 @@ function MCPToolSelectDialog({ tool_id: serverInfo.serverName, metadata: { ...serverInfo.metadata, - description: `${localize('com_ui_tool_collection_prefix')} ${serverInfo.serverName}`, }, }; diff --git a/client/src/data-provider/MCP/index.ts b/client/src/data-provider/MCP/index.ts index 3cf1ef310b..d0720956a0 100644 --- a/client/src/data-provider/MCP/index.ts +++ b/client/src/data-provider/MCP/index.ts @@ -1 +1,2 @@ export * from './queries'; +export * from './mutations'; diff --git a/client/src/data-provider/MCP/mutations.ts b/client/src/data-provider/MCP/mutations.ts new file mode 100644 index 0000000000..19fa2c3653 --- /dev/null +++ b/client/src/data-provider/MCP/mutations.ts @@ -0,0 +1,141 @@ +import { useMutation, useQueryClient, UseMutationResult } from '@tanstack/react-query'; +import { dataService, QueryKeys, ResourceType } from 'librechat-data-provider'; +import type * as t from 'librechat-data-provider'; + +/** + * Hook for creating a new MCP server + */ +export const useCreateMCPServerMutation = (options?: { + onMutate?: (variables: t.MCPServerCreateParams) => void; + onSuccess?: ( + data: t.MCPServerDBObjectResponse, + variables: t.MCPServerCreateParams, + context: unknown, + ) => void; + onError?: (error: Error, variables: t.MCPServerCreateParams, context: unknown) => void; +}): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation((data: t.MCPServerCreateParams) => dataService.createMCPServer(data), { + onMutate: (variables) => options?.onMutate?.(variables), + onError: (error, variables, context) => options?.onError?.(error, variables, context), + onSuccess: (newServer, variables, context) => { + const listRes = queryClient.getQueryData([QueryKeys.mcpServers]); + if (listRes) { + queryClient.setQueryData([QueryKeys.mcpServers], { + ...listRes, + [newServer.serverName!]: newServer, + }); + } + + queryClient.invalidateQueries([QueryKeys.mcpServers]); + queryClient.invalidateQueries([QueryKeys.mcpTools]); + queryClient.invalidateQueries([QueryKeys.mcpAuthValues]); + queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); + queryClient.invalidateQueries([ + QueryKeys.effectivePermissions, + 'all', + ResourceType.MCPSERVER, + ]); + + return options?.onSuccess?.(newServer, variables, context); + }, + }); +}; + +/** + * Hook for updating an existing MCP server + */ +export const useUpdateMCPServerMutation = (options?: { + onMutate?: (variables: { serverName: string; data: t.MCPServerUpdateParams }) => void; + onSuccess?: ( + data: t.MCPServerDBObjectResponse, + variables: { serverName: string; data: t.MCPServerUpdateParams }, + context: unknown, + ) => void; + onError?: ( + error: Error, + variables: { serverName: string; data: t.MCPServerUpdateParams }, + context: unknown, + ) => void; +}): UseMutationResult< + t.MCPServerDBObjectResponse, + Error, + { serverName: string; data: t.MCPServerUpdateParams } +> => { + const queryClient = useQueryClient(); + + return useMutation( + ({ serverName, data }: { serverName: string; data: t.MCPServerUpdateParams }) => + dataService.updateMCPServer(serverName, data), + { + onMutate: (variables: { serverName: string; data: t.MCPServerUpdateParams }) => + options?.onMutate?.(variables), + onError: ( + error: Error, + variables: { serverName: string; data: t.MCPServerUpdateParams }, + context: unknown, + ) => options?.onError?.(error, variables, context), + onSuccess: ( + updatedServer: t.MCPServerDBObjectResponse, + variables: { serverName: string; data: t.MCPServerUpdateParams }, + context: unknown, + ) => { + // Update list cache + const listRes = queryClient.getQueryData([QueryKeys.mcpServers]); + if (listRes) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, ...oldServer } = listRes[variables.serverName!]; + listRes[variables.serverName!] = { ...oldServer, ...updatedServer }; + + queryClient.setQueryData([QueryKeys.mcpServers], { + ...listRes, + }); + } + + queryClient.setQueryData([QueryKeys.mcpServer, variables.serverName], updatedServer); + queryClient.invalidateQueries([QueryKeys.mcpServers]); + queryClient.invalidateQueries([QueryKeys.mcpTools]); + queryClient.invalidateQueries([QueryKeys.mcpAuthValues]); + queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); + + return options?.onSuccess?.(updatedServer, variables, context); + }, + }, + ); +}; + +/** + * Hook for deleting an MCP server + */ +export const useDeleteMCPServerMutation = (options?: { + onMutate?: (variables: string) => void; + onSuccess?: (_data: { success: boolean }, variables: string, context: unknown) => void; + onError?: (error: Error, variables: string, context: unknown) => void; +}): UseMutationResult<{ success: boolean }, Error, string> => { + const queryClient = useQueryClient(); + + return useMutation((serverName: string) => dataService.deleteMCPServer(serverName), { + onMutate: (variables) => options?.onMutate?.(variables), + onError: (error, variables, context) => options?.onError?.(error, variables, context), + onSuccess: (_data, serverName, context) => { + // Update list cache by removing the deleted server (immutable update) + const listRes = queryClient.getQueryData([QueryKeys.mcpServers]); + if (listRes) { + const { [serverName]: _removed, ...remaining } = listRes; + queryClient.setQueryData([QueryKeys.mcpServers], remaining); + } + + // Remove from specific server query cache + queryClient.removeQueries([QueryKeys.mcpServer, serverName]); + + // Invalidate list query to ensure consistency + queryClient.invalidateQueries([QueryKeys.mcpServers]); + queryClient.invalidateQueries([QueryKeys.mcpTools]); + queryClient.invalidateQueries([QueryKeys.mcpAuthValues]); + queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); + + return options?.onSuccess?.(_data, serverName, context); + }, + }); +}; diff --git a/client/src/data-provider/roles.ts b/client/src/data-provider/roles.ts index a60047cecb..46edcd2dc9 100644 --- a/client/src/data-provider/roles.ts +++ b/client/src/data-provider/roles.ts @@ -6,6 +6,7 @@ import { memoryPermissionsSchema, marketplacePermissionsSchema, peoplePickerPermissionsSchema, + mcpServersPermissionsSchema, } from 'librechat-data-provider'; import type { UseQueryOptions, @@ -171,6 +172,42 @@ export const useUpdatePeoplePickerPermissionsMutation = ( ); }; +export const useUpdateMCPServersPermissionsMutation = ( + options?: t.UpdateMCPServersPermOptions, +): UseMutationResult< + t.UpdatePermResponse, + t.TError | undefined, + t.UpdateMCPServersPermVars, + unknown +> => { + const queryClient = useQueryClient(); + const { onMutate, onSuccess, onError } = options ?? {}; + return useMutation( + (variables) => { + mcpServersPermissionsSchema.partial().parse(variables.updates); + return dataService.updateMCPServersPermissions(variables); + }, + { + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries([QueryKeys.roles, variables.roleName]); + if (onSuccess) { + onSuccess(data, variables, context); + } + }, + onError: (...args) => { + const error = args[0]; + if (error != null) { + console.error('Failed to update MCP servers permissions:', error); + } + if (onError) { + onError(...args); + } + }, + onMutate, + }, + ); +}; + export const useUpdateMarketplacePermissionsMutation = ( options?: t.UpdateMarketplacePermOptions, ): UseMutationResult< diff --git a/client/src/hooks/MCP/index.ts b/client/src/hooks/MCP/index.ts index 0ee7e9494d..705ca58910 100644 --- a/client/src/hooks/MCP/index.ts +++ b/client/src/hooks/MCP/index.ts @@ -1,5 +1,5 @@ export * from './useMCPConnectionStatus'; export * from './useMCPSelect'; export * from './useVisibleTools'; -export { useMCPServerManager } from './useMCPServerManager'; +export * from './useMCPServerManager'; export { useRemoveMCPTool } from './useRemoveMCPTool'; diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index e3f47502dc..89cc62aed4 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -1,11 +1,12 @@ import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; -import { Constants, QueryKeys, MCPOptions } from 'librechat-data-provider'; +import { Constants, QueryKeys, MCPOptions, ResourceType } from 'librechat-data-provider'; import { useCancelMCPOAuthMutation, useUpdateUserPluginsMutation, useReinitializeMCPServerMutation, + useGetAllEffectivePermissionsQuery, } from 'librechat-data-provider/react-query'; import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider'; import type { ConfigFieldDetail } from '~/common'; @@ -15,9 +16,9 @@ import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider'; export interface MCPServerDefinition { serverName: string; config: MCPOptions; - mcp_id?: string; - _id?: string; // MongoDB ObjectId for database servers (used for permissions) + dbId?: string; // MongoDB ObjectId for database servers (used for permissions) effectivePermissions: number; // Permission bits (VIEW=1, EDIT=2, DELETE=4, SHARE=8) + consumeOnly?: boolean; } interface ServerState { @@ -36,35 +37,44 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const { data: loadedServers, isLoading } = useMCPServersQuery(); + // Fetch effective permissions for all MCP servers + const { data: permissionsMap } = useGetAllEffectivePermissionsQuery(ResourceType.MCPSERVER); + const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [selectedToolForConfig, setSelectedToolForConfig] = useState(null); const previousFocusRef = useRef(null); - const configuredServers = useMemo(() => { - if (!loadedServers) return []; - return Object.keys(loadedServers).filter((name) => loadedServers[name]?.chatMenu !== false); - }, [loadedServers]); const availableMCPServers: MCPServerDefinition[] = useMemo(() => { const definitions: MCPServerDefinition[] = []; if (loadedServers) { for (const [serverName, metadata] of Object.entries(loadedServers)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { _id, mcp_id, effectivePermissions, author, updatedAt, createdAt, ...config } = - metadata; + const { dbId, consumeOnly, ...config } = metadata; + + // Get effective permissions from the permissions map using _id + // Fall back to 1 (VIEW) for YAML-based servers without _id + const effectivePermissions = dbId && permissionsMap?.[dbId] ? permissionsMap[dbId] : 1; + definitions.push({ serverName, - mcp_id, - effectivePermissions: effectivePermissions || 1, + dbId, + effectivePermissions, + consumeOnly, config, }); } } return definitions; - }, [loadedServers]); + }, [loadedServers, permissionsMap]); + + // Memoize filtered servers for useMCPSelect to prevent infinite loops + const selectableServers = useMemo( + () => availableMCPServers.filter((s) => s.config.chatMenu !== false && !s.consumeOnly), + [availableMCPServers], + ); const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId, - servers: availableMCPServers, + servers: selectableServers, }); const mcpValuesRef = useRef(mcpValues); @@ -73,6 +83,14 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin mcpValuesRef.current = mcpValues; }, [mcpValues]); + // Check if specific permission bit is set + const checkEffectivePermission = useCallback( + (effectivePermissions: number, permissionBit: number): boolean => { + return (effectivePermissions & permissionBit) !== 0; + }, + [], + ); + const reinitializeMutation = useReinitializeMCPServerMutation(); const cancelOAuthMutation = useCancelMCPOAuthMutation(); @@ -98,8 +116,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const [serverStates, setServerStates] = useState>(() => { const initialStates: Record = {}; - configuredServers.forEach((serverName) => { - initialStates[serverName] = { + availableMCPServers.forEach((server) => { + initialStates[server.serverName] = { isInitializing: false, oauthUrl: null, oauthStartTime: null, @@ -111,7 +129,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin }); const { connectionStatus } = useMCPConnectionStatus({ - enabled: !isLoading && configuredServers.length > 0, + enabled: !isLoading && availableMCPServers.length > 0, }); const updateServerState = useCallback((serverName: string, updates: Partial) => { @@ -366,7 +384,12 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin cancelOAuthMutation.mutate(serverName, { onSuccess: () => { cleanupServerState(serverName); - queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); + Promise.all([ + queryClient.invalidateQueries([QueryKeys.mcpServers]), + queryClient.invalidateQueries([QueryKeys.mcpTools]), + queryClient.invalidateQueries([QueryKeys.mcpAuthValues]), + queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]), + ]); showToast({ message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }), @@ -629,6 +652,8 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin return { availableMCPServers, + /** MCP servers filtered for chat menu selection (chatMenu !== false && !consumeOnly) */ + selectableServers, availableMCPServersMap: loadedServers, isLoading, connectionStatus, @@ -655,5 +680,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin handleRevoke, getServerStatusIconProps, getConfigDialogProps, + checkEffectivePermission, }; } diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 6ec798eaa6..ab6904247b 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -10,6 +10,7 @@ import { isAssistantsEndpoint, } from 'librechat-data-provider'; import type { TInterfaceConfig, TEndpointsConfig } from 'librechat-data-provider'; +import MCPBuilderPanel from '~/components/SidePanel/MCPBuilder/MCPBuilderPanel'; import type { NavLink } from '~/common'; import AgentPanelSwitch from '~/components/SidePanel/Agents/AgentPanelSwitch'; import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel'; @@ -18,7 +19,6 @@ import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch'; import PromptsAccordion from '~/components/Prompts/PromptsAccordion'; import Parameters from '~/components/SidePanel/Parameters/Panel'; import FilesPanel from '~/components/SidePanel/Files/Panel'; -import MCPPanel from '~/components/SidePanel/MCP/MCPPanel'; import { useHasAccess, useMCPServerManager } from '~/hooks'; export default function useSideNavLinks({ @@ -60,7 +60,10 @@ export default function useSideNavLinks({ permissionType: PermissionTypes.AGENTS, permission: Permissions.CREATE, }); - + const hasAccessToUseMCPSettings = useHasAccess({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.USE, + }); const { availableMCPServers } = useMCPServerManager(); const Links = useMemo(() => { @@ -152,21 +155,13 @@ export default function useSideNavLinks({ }); } - if ( - availableMCPServers && - availableMCPServers.some( - (server: any) => - (server.config.customUserVars && Object.keys(server.config.customUserVars).length > 0) || - server.config.isOAuth || - server.config.startup === false, - ) - ) { + if (hasAccessToUseMCPSettings && availableMCPServers && availableMCPServers.length > 0) { links.push({ title: 'com_nav_setting_mcp', label: '', icon: MCPIcon, - id: 'mcp-settings', - Component: MCPPanel, + id: 'mcp-builder', + Component: MCPBuilderPanel, }); } @@ -180,19 +175,20 @@ export default function useSideNavLinks({ return links; }, [ - endpointsConfig, - interfaceConfig.parameters, - keyProvided, - endpointType, endpoint, + endpointsConfig, + keyProvided, hasAccessToAgents, + hasAccessToCreateAgents, hasAccessToPrompts, hasAccessToMemories, hasAccessToReadMemories, + interfaceConfig.parameters, + endpointType, hasAccessToBookmarks, - hasAccessToCreateAgents, - hidePanel, availableMCPServers, + hasAccessToUseMCPSettings, + hidePanel, ]); return Links; diff --git a/client/src/hooks/Sharing/useResourcePermissionState.ts b/client/src/hooks/Sharing/useResourcePermissionState.ts index da84244f8f..0a9f8ce386 100644 --- a/client/src/hooks/Sharing/useResourcePermissionState.ts +++ b/client/src/hooks/Sharing/useResourcePermissionState.ts @@ -8,7 +8,7 @@ import { getResourceConfig } from '~/utils'; /** * Hook to manage resource permission state including current shares, public access, and mutations - * @param resourceType - Type of resource (e.g., ResourceType.AGENT, ResourceType.PROMPTGROUP) + * @param resourceType - Type of resource (e.g., ResourceType.AGENT, ResourceType.PROMPTGROUP, ResourceType.MCPSERVER) * @param resourceDbId - Database ID of the resource * @param isModalOpen - Whether the modal is open (for effect dependencies) * @returns Object with permission state and update mutation diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 0c420fe2b3..d4e0756608 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -75,10 +75,8 @@ "com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity", "com_agents_mcp_description_placeholder": "Explain what it does in a few words", "com_agents_mcp_icon_size": "Minimum size 128 x 128 px", - "com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services", "com_agents_mcp_name_placeholder": "Custom Tool", "com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat", - "com_agents_mcps_disabled": "You need to create an agent before adding MCPs.", "com_agents_missing_name": "Please type in a name before creating an agent.", "com_agents_missing_provider_model": "Please select a provider and model before creating an agent.", "com_agents_name_placeholder": "Optional: The name of the agent", @@ -536,6 +534,7 @@ "com_nav_long_audio_warning": "Longer texts will take longer to process.", "com_nav_maximize_chat_space": "Maximize chat space", "com_nav_mcp_configure_server": "Configure {{0}}", + "com_nav_mcp_status_connected": "Connected", "com_nav_mcp_status_connecting": "{{0}} - Connecting", "com_nav_mcp_vars_update_error": "Error updating MCP custom user variables", "com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.", @@ -593,7 +592,6 @@ "com_sidepanel_conversation_tags": "Bookmarks", "com_sidepanel_hide_panel": "Hide Panel", "com_sidepanel_manage_files": "Manage Files", - "com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.", "com_sidepanel_parameters": "Parameters", "com_sources_agent_file": "Source Document", "com_sources_agent_files": "Agent Files", @@ -631,6 +629,10 @@ "com_ui_add_web_search_api_keys": "Add Web Search API Keys", "com_ui_add_mcp": "Add MCP", "com_ui_add_mcp_server": "Add MCP Server", + "com_ui_no_mcp_servers": "No MCP servers yet", + "com_ui_add_first_mcp_server": "Create your first MCP server to get started", + "com_ui_mcp_server_created": "MCP server created successfully", + "com_ui_mcp_server_updated": "MCP server updated successfully", "com_ui_add_model_preset": "Add a model or preset for an additional response", "com_ui_add_multi_conversation": "Add multi-conversation", "com_ui_add_special_variables": "Add Special Variables", @@ -691,6 +693,9 @@ "com_ui_agents_allow_create": "Allow creating Agents", "com_ui_agents_allow_share": "Allow sharing Agents", "com_ui_agents_allow_use": "Allow using Agents", + "com_ui_mcp_servers_allow_create": "Allow users to create MCP servers", + "com_ui_mcp_servers_allow_share": "Allow users to share MCP servers", + "com_ui_mcp_servers_allow_use": "Allow users to use MCP servers", "com_ui_all": "all", "com_ui_all_proper": "All", "com_ui_analyzing": "Analyzing", @@ -725,7 +730,6 @@ "com_ui_authentication": "Authentication", "com_ui_authentication_type": "Authentication Type", "com_ui_auto": "Auto", - "com_ui_available_tools": "Available Tools", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", "com_ui_azure_ad": "Entra ID", @@ -864,10 +868,6 @@ "com_ui_delete_confirm_strong": "This will delete {{title}}", "com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.", "com_ui_delete_conversation": "Delete chat?", - "com_ui_delete_mcp": "Delete MCP", - "com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?", - "com_ui_delete_mcp_error": "Failed to delete MCP server", - "com_ui_delete_mcp_success": "MCP server deleted successfully", "com_ui_delete_memory": "Delete Memory", "com_ui_delete_not_allowed": "Delete operation is not allowed", "com_ui_delete_preset": "Delete Preset?", @@ -887,6 +887,7 @@ "com_ui_deselect_all": "Deselect All", "com_ui_detailed": "Detailed", "com_ui_disabling": "Disabling...", + "com_ui_done": "Done", "com_ui_download": "Download", "com_ui_download_artifact": "Download Artifact", "com_ui_download_backup": "Download Backup Codes", @@ -904,9 +905,9 @@ "com_ui_edit": "Edit", "com_ui_edit_editing_image": "Editing image", "com_ui_edit_mcp_server": "Edit MCP Server", + "com_ui_edit_mcp_server_dialog_description": "Unique Server Identifier: {{serverName}}", "com_ui_edit_memory": "Edit Memory", "com_ui_edit_preset_title": "Edit Preset - {{title}}", - "com_ui_edit_server": "Edit {{serverName}}", "com_ui_edit_prompt_page": "Edit Prompt Page", "com_ui_editable_message": "Editable Message", "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", @@ -1047,7 +1048,7 @@ "com_ui_mcp_initialized_success": "MCP server '{{0}}' initialized successfully", "com_ui_mcp_oauth_cancelled": "OAuth login cancelled for {{0}}", "com_ui_mcp_oauth_timeout": "OAuth login timed out for {{0}}", - "com_ui_mcp_server_not_found": "Server not found.", + "com_ui_mcp_server": "MCP Server", "com_ui_mcp_servers": "MCP Servers", "com_ui_mcp_update_var": "Update {{0}}", "com_ui_mcp_url": "MCP Server URL", @@ -1109,7 +1110,6 @@ "com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.", "com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.", "com_ui_oauth_error_title": "Authentication Failed", - "com_ui_oauth_revoke": "Revoke", "com_ui_oauth_success_description": "Your authentication was successful. This window will close in", "com_ui_oauth_success_title": "Authentication Successful", "com_ui_of": "of", @@ -1154,6 +1154,9 @@ "com_ui_quality": "Quality", "com_ui_read_aloud": "Read aloud", "com_ui_redirecting_to_provider": "Redirecting to {{0}}, please wait...", + "com_ui_redirect_uri": "Redirect URI", + "com_ui_redirect_uri_info": "The redirect URI will be provided after the server is created. Configure it in your OAuth provider settings.", + "com_ui_redirect_uri_instructions": "Copy this redirect URI and configure it in your OAuth provider settings.", "com_ui_reference_saved_memories": "Reference saved memories", "com_ui_reference_saved_memories_description": "Allow the assistant to reference and use your saved memories when responding", "com_ui_refresh": "Refresh", @@ -1199,6 +1202,18 @@ "com_ui_role_select": "Role", "com_ui_role_viewer": "Viewer", "com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it", + "com_ui_mcp_server_role_viewer": "MCP Server Viewer", + "com_ui_mcp_server_role_viewer_desc": "Can view and use MCP servers", + "com_ui_mcp_server_role_editor": "MCP Server Editor", + "com_ui_mcp_server_role_editor_desc": "Can view, use, and edit MCP servers", + "com_ui_mcp_server_role_owner": "MCP Server Owner", + "com_ui_mcp_server_role_owner_desc": "Full control over MCP servers", + "com_ui_mcp_server_delete_confirm": "Are you sure you want to delete this MCP server?", + "com_ui_mcp_server_deleted": "MCP server deleted successfully", + "com_ui_mcp_title_invalid": "Title can only contain letters, numbers, and spaces", + "com_ui_mcp_server_type": "Server Type", + "com_ui_mcp_type_streamable_http": "Streamable HTTPS", + "com_ui_mcp_type_sse": "SSE", "com_ui_roleplay": "Roleplay", "com_ui_rotate": "Rotate", "com_ui_rotate_90": "Rotate 90 degrees", @@ -1312,8 +1327,6 @@ "com_ui_unpin": "Unpin", "com_ui_untitled": "Untitled", "com_ui_update": "Update", - "com_ui_update_mcp_error": "There was an error creating or updating the MCP.", - "com_ui_update_mcp_success": "Successfully created or updated MCP", "com_ui_upload": "Upload", "com_ui_upload_agent_avatar": "Successfully updated agent avatar", "com_ui_upload_agent_avatar_label": "Upload agent avatar image", diff --git a/client/src/utils/resources.ts b/client/src/utils/resources.ts index dc18ea9b8b..f7c3586dfb 100644 --- a/client/src/utils/resources.ts +++ b/client/src/utils/resources.ts @@ -36,6 +36,17 @@ export const RESOURCE_CONFIGS: Record = { `Manage permissions for ${name && name !== '' ? `"${name}"` : 'prompt'}`, getCopyUrlMessage: () => 'Prompt URL copied', }, + [ResourceType.MCPSERVER]: { + resourceType: ResourceType.MCPSERVER, + defaultViewerRoleId: AccessRoleIds.MCPSERVER_VIEWER, + defaultEditorRoleId: AccessRoleIds.MCPSERVER_EDITOR, + defaultOwnerRoleId: AccessRoleIds.MCPSERVER_OWNER, + getResourceName: (name?: string) => (name && name !== '' ? `"${name}"` : 'MCP server'), + getShareMessage: (name?: string) => (name && name !== '' ? `"${name}"` : 'MCP server'), + getManageMessage: (name?: string) => + `Manage permissions for ${name && name !== '' ? `"${name}"` : 'MCP server'}`, + getCopyUrlMessage: () => 'MCP Server URL copied', + }, }; export const getResourceConfig = (resourceType: ResourceType): ResourceConfig | undefined => { diff --git a/client/src/utils/roles.ts b/client/src/utils/roles.ts index 7848e6b340..8bc38b7d52 100644 --- a/client/src/utils/roles.ts +++ b/client/src/utils/roles.ts @@ -35,6 +35,19 @@ export const ROLE_LOCALIZATIONS = { name: 'com_ui_role_owner' as const, description: 'com_ui_role_owner_desc' as const, } as const, + // MCPServer roles + mcpServer_viewer: { + name: 'com_ui_mcp_server_role_viewer' as const, + description: 'com_ui_mcp_server_role_viewer_desc' as const, + } as const, + mcpServer_editor: { + name: 'com_ui_mcp_server_role_editor' as const, + description: 'com_ui_mcp_server_role_editor_desc' as const, + } as const, + mcpServer_owner: { + name: 'com_ui_mcp_server_role_owner' as const, + description: 'com_ui_mcp_server_role_owner_desc' as const, + } as const, }; /** diff --git a/librechat.example.yaml b/librechat.example.yaml index f163f8d4ac..da0b0b7bb1 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -94,8 +94,17 @@ interface: groups: true roles: true marketplace: - use: false + use: false fileCitations: true + mcpServers: + # MCP Servers configuration + # Controls user permissions for MCP (Model Context Protocol) server management + # - use: Allow users to use configured MCP servers + # - create: Allow users to create and manage new MCP servers + # - share: Allow users to share MCP servers with other users + use: false + create: false + share: false # Temporary chat retention period in hours (default: 720, min: 1, max: 8760) # temporaryChatRetention: 1 @@ -291,7 +300,7 @@ endpoints: apiKey: '${OPENROUTER_KEY}' baseURL: 'https://openrouter.ai/api/v1' headers: - x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' + x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' models: default: ['meta-llama/llama-3-70b-instruct'] fetch: true @@ -308,9 +317,10 @@ endpoints: apiKey: '${HELICONE_KEY}' baseURL: 'https://ai-gateway.helicone.ai' headers: - x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' + x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' models: - default: ['gpt-4o-mini', 'claude-4.5-sonnet', 'llama-3.1-8b-instruct', 'gemini-2.5-flash-lite'] + default: + ['gpt-4o-mini', 'claude-4.5-sonnet', 'llama-3.1-8b-instruct', 'gemini-2.5-flash-lite'] fetch: true titleConvo: true titleModel: 'gpt-4o-mini' diff --git a/packages/api/src/acl/accessControlService.spec.ts b/packages/api/src/acl/accessControlService.spec.ts new file mode 100644 index 0000000000..ad1dcf18ac --- /dev/null +++ b/packages/api/src/acl/accessControlService.spec.ts @@ -0,0 +1,1084 @@ +import mongoose, { Types, Model } from 'mongoose'; +import { createModels, createMethods, RoleBits } from '@librechat/data-schemas'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { ResourceType, AccessRoleIds, PrincipalType } from 'librechat-data-provider'; +import { AccessControlService } from './accessControlService'; + +// Mock the logger +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + }, +})); + +let mongoServer: MongoMemoryServer; +let AclEntry: Model; +let service: AccessControlService; +let dbMethods: ReturnType; + +// Mock getUserPrincipals to control test scenarios +const mockGetUserPrincipals = jest.fn(); + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + + // Initialize all models + createModels(mongoose); + + AclEntry = mongoose.models.AclEntry; + + // Create methods and seed default roles + dbMethods = createMethods(mongoose); + await dbMethods.seedDefaultRoles(); + + // Create service instance + service = new AccessControlService(mongoose); + + // Mock getUserPrincipals in the dbMethods + const originalMethods = service['_dbMethods']; + service['_dbMethods'] = { + ...originalMethods, + getUserPrincipals: mockGetUserPrincipals, + }; +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + // Clear test data but keep seeded roles + await AclEntry.deleteMany({}); + mockGetUserPrincipals.mockReset(); +}); + +describe('AccessControlService', () => { + // Common test data + const userId = new Types.ObjectId(); + const groupId = new Types.ObjectId(); + const resourceId = new Types.ObjectId(); + const grantedById = new Types.ObjectId(); + + describe('grantPermission', () => { + describe('validation', () => { + test('should throw error for invalid principal type', async () => { + await expect( + service.grantPermission({ + principalType: 'invalid' as PrincipalType, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid principal type: invalid'); + }); + + test('should throw error for missing principalId with user type', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Principal ID is required for user, group, and role principals'); + }); + + test('should throw error for missing principalId with group type', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Principal ID is required for user, group, and role principals'); + }); + + test('should throw error for missing principalId with role type', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.ROLE, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Principal ID is required for user, group, and role principals'); + }); + + test('should throw error for invalid role ID (empty string)', async () => { + // Empty string is falsy, so it triggers the "principalId required" check first + await expect( + service.grantPermission({ + principalType: PrincipalType.ROLE, + principalId: '', + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Principal ID is required for user, group, and role principals'); + }); + + test('should throw error for invalid role ID (whitespace only)', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.ROLE, + principalId: ' ', + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid role ID:'); + }); + + test('should throw error for invalid user principal ID (non-ObjectId)', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: 'invalid-id', + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid principal ID: invalid-id'); + }); + + test('should throw error for invalid resource ID', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: 'invalid-id', + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid resource ID: invalid-id'); + }); + + test('should throw error for invalid resource type', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: 'invalidType' as ResourceType, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid resourceType: invalidType'); + }); + }); + + describe('role lookup', () => { + test('should throw error for non-existent role', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: 'non_existent_role' as AccessRoleIds, + grantedBy: grantedById, + }), + ).rejects.toThrow('Role non_existent_role not found'); + }); + + test('should throw error for role-resource type mismatch', async () => { + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER, // PromptGroup role for agent resource + grantedBy: grantedById, + }), + ).rejects.toThrow('Role promptGroup_viewer is for promptGroup resources, not agent'); + }); + }); + + describe('successful grant', () => { + test('should grant permission to a user with a role', async () => { + const entry = await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry!.principalType).toBe(PrincipalType.USER); + expect(entry!.principalId!.toString()).toBe(userId.toString()); + expect(entry!.resourceType).toBe(ResourceType.AGENT); + expect(entry!.resourceId.toString()).toBe(resourceId.toString()); + expect(entry!.permBits).toBe(RoleBits.VIEWER); + }); + + test('should grant permission to a group with a role', async () => { + const entry = await service.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: groupId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry!.principalType).toBe(PrincipalType.GROUP); + expect(entry!.principalId!.toString()).toBe(groupId.toString()); + expect(entry!.permBits).toBe(RoleBits.EDITOR); + }); + + test('should grant public permission with a role', async () => { + const entry = await service.grantPermission({ + principalType: PrincipalType.PUBLIC, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry!.principalType).toBe(PrincipalType.PUBLIC); + expect(entry!.principalId).toBeUndefined(); + expect(entry!.permBits).toBe(RoleBits.VIEWER); + }); + + test('should grant permission to a role principal', async () => { + const entry = await service.grantPermission({ + principalType: PrincipalType.ROLE, + principalId: 'admin', + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + expect(entry).toBeDefined(); + expect(entry!.principalType).toBe(PrincipalType.ROLE); + expect(entry!.principalId).toBe('admin'); + expect(entry!.permBits).toBe(RoleBits.EDITOR); + }); + + test('should update existing permission when granting to same principal and resource', async () => { + // First grant with viewer role + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + // Then update to editor role + const updated = await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + expect(updated!.permBits).toBe(RoleBits.EDITOR); + + // Verify there's only one entry + const entries = await AclEntry.find({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId, + }); + expect(entries).toHaveLength(1); + }); + }); + }); + + describe('findAccessibleResources', () => { + const resource1 = new Types.ObjectId(); + const resource2 = new Types.ObjectId(); + const resource3 = new Types.ObjectId(); + + beforeEach(async () => { + // User can view resource 1 + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: resource1, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + // User can edit resource 2 + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: resource2, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + // Group can view resource 3 + await service.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: groupId, + resourceType: ResourceType.AGENT, + resourceId: resource3, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + }); + + describe('validation errors', () => { + test('should throw error when requiredPermissions is not a positive number', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + await expect( + service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: 0, + }), + ).rejects.toThrow('requiredPermissions must be a positive number'); + }); + + test('should throw error when requiredPermissions is negative', async () => { + await expect( + service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: -1, + }), + ).rejects.toThrow('requiredPermissions must be a positive number'); + }); + + test('should return empty array for invalid resource type (error is caught)', async () => { + // The service catches invalid resourceType errors and returns empty array + const result = await service.findAccessibleResources({ + userId, + resourceType: 'invalid' as ResourceType, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + }); + + describe('empty principals', () => { + test('should return empty array when no principals found', async () => { + mockGetUserPrincipals.mockResolvedValue([]); + + const result = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + }); + + describe('successful queries', () => { + test('should find resources user can view', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + const viewableResources = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: 1, // VIEW + }); + + expect(viewableResources).toHaveLength(2); + const resourceIds = viewableResources.map((id) => id.toString()); + expect(resourceIds).toContain(resource1.toString()); + expect(resourceIds).toContain(resource2.toString()); + }); + + test('should find resources user can edit', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + const editableResources = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: 3, // EDIT + }); + + expect(editableResources).toHaveLength(1); + expect(editableResources[0].toString()).toBe(resource2.toString()); + }); + + test('should find resources accessible via group membership', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ]); + + const viewableResources = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: 1, // VIEW + }); + + expect(viewableResources).toHaveLength(3); + }); + + test('should pass role when provided', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]); + + await service.findAccessibleResources({ + userId, + role: 'admin', + resourceType: ResourceType.AGENT, + requiredPermissions: 1, + }); + + expect(mockGetUserPrincipals).toHaveBeenCalledWith({ + userId, + role: 'admin', + }); + }); + }); + }); + + describe('findPubliclyAccessibleResources', () => { + const publicResource1 = new Types.ObjectId(); + const publicResource2 = new Types.ObjectId(); + const privateResource = new Types.ObjectId(); + + beforeEach(async () => { + // Public can view resource 1 + await service.grantPermission({ + principalType: PrincipalType.PUBLIC, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId: publicResource1, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + // Public can edit resource 2 + await service.grantPermission({ + principalType: PrincipalType.PUBLIC, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId: publicResource2, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + // Private resource - only user access + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: privateResource, + accessRoleId: AccessRoleIds.AGENT_OWNER, + grantedBy: grantedById, + }); + }); + + describe('validation', () => { + test('should throw error when requiredPermissions is not a positive number', async () => { + await expect( + service.findPubliclyAccessibleResources({ + resourceType: ResourceType.AGENT, + requiredPermissions: 0, + }), + ).rejects.toThrow('requiredPermissions must be a positive number'); + }); + + test('should return empty array for invalid resource type (error is caught)', async () => { + // The service catches invalid resourceType errors and returns empty array + const result = await service.findPubliclyAccessibleResources({ + resourceType: 'invalid' as ResourceType, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + }); + + describe('finding public resources', () => { + test('should find publicly viewable resources', async () => { + const publicResources = await service.findPubliclyAccessibleResources({ + resourceType: ResourceType.AGENT, + requiredPermissions: 1, // VIEW + }); + + expect(publicResources).toHaveLength(2); + const resourceIds = publicResources.map((id) => id.toString()); + expect(resourceIds).toContain(publicResource1.toString()); + expect(resourceIds).toContain(publicResource2.toString()); + expect(resourceIds).not.toContain(privateResource.toString()); + }); + + test('should find publicly editable resources', async () => { + const editableResources = await service.findPubliclyAccessibleResources({ + resourceType: ResourceType.AGENT, + requiredPermissions: 3, // EDIT + }); + + expect(editableResources).toHaveLength(1); + expect(editableResources[0].toString()).toBe(publicResource2.toString()); + }); + + test('should return empty array when no public permissions exist', async () => { + const noPublicResources = await service.findPubliclyAccessibleResources({ + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: 1, + }); + + expect(noPublicResources).toEqual([]); + }); + }); + }); + + describe('getResourcePermissionsMap', () => { + const resource1 = new Types.ObjectId(); + const resource2 = new Types.ObjectId(); + const resource3 = new Types.ObjectId(); + + beforeEach(async () => { + // User has VIEW on resource1 + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: resource1, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + // User has EDIT on resource2 + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: resource2, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + // Group has EDIT on resource1 (higher permission) + await service.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: groupId, + resourceType: ResourceType.AGENT, + resourceId: resource1, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + // resource3 has no permissions + }); + + describe('empty arrays', () => { + test('should return empty map for empty resourceIds array', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + const permissionsMap = await service.getResourcePermissionsMap({ + userId, + role: 'user', + resourceType: ResourceType.AGENT, + resourceIds: [], + }); + + expect(permissionsMap).toBeInstanceOf(Map); + expect(permissionsMap.size).toBe(0); + }); + + test('should throw on invalid resource type', async () => { + await expect( + service.getResourcePermissionsMap({ + userId, + role: 'user', + resourceType: 'invalid' as ResourceType, + resourceIds: [resource1], + }), + ).rejects.toThrow('Invalid resourceType: invalid'); + }); + }); + + describe('batch queries', () => { + test('should get permissions for multiple resources in single query', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.PUBLIC }, + ]); + + const permissionsMap = await service.getResourcePermissionsMap({ + userId, + role: 'user', + resourceType: ResourceType.AGENT, + resourceIds: [resource1, resource2, resource3], + }); + + expect(permissionsMap).toBeInstanceOf(Map); + expect(permissionsMap.size).toBe(2); // resource1 and resource2 + expect(permissionsMap.get(resource1.toString())).toBe(RoleBits.VIEWER); + expect(permissionsMap.get(resource2.toString())).toBe(RoleBits.EDITOR); + expect(permissionsMap.get(resource3.toString())).toBeUndefined(); + }); + + test('should combine permissions from multiple principals', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ]); + + const permissionsMap = await service.getResourcePermissionsMap({ + userId, + role: 'user', + resourceType: ResourceType.AGENT, + resourceIds: [resource1, resource2], + }); + + expect(permissionsMap.size).toBe(2); + // Resource1 should have VIEW (1) | EDIT (3) = 3 from combined user+group + expect(permissionsMap.get(resource1.toString())).toBe(RoleBits.EDITOR); + expect(permissionsMap.get(resource2.toString())).toBe(RoleBits.EDITOR); + }); + + test('should use role optimization when provided', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]); + + await service.getResourcePermissionsMap({ + userId, + role: 'admin', + resourceType: ResourceType.AGENT, + resourceIds: [resource1], + }); + + expect(mockGetUserPrincipals).toHaveBeenCalledWith({ userId, role: 'admin' }); + }); + }); + }); + + describe('removeAllPermissions', () => { + const resourceToDelete = new Types.ObjectId(); + + beforeEach(async () => { + // Grant multiple permissions to the resource + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + await service.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: groupId, + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + + await service.grantPermission({ + principalType: PrincipalType.PUBLIC, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + }); + + describe('validation', () => { + test('should throw error for invalid resource type', async () => { + await expect( + service.removeAllPermissions({ + resourceType: 'invalid' as ResourceType, + resourceId: resourceToDelete, + }), + ).rejects.toThrow('Invalid resourceType: invalid'); + }); + + test('should throw error for invalid resource ID', async () => { + await expect( + service.removeAllPermissions({ + resourceType: ResourceType.AGENT, + resourceId: 'invalid-id', + }), + ).rejects.toThrow('Invalid resource ID: invalid-id'); + }); + }); + + describe('cleanup', () => { + test('should delete all permissions for a resource', async () => { + // Verify permissions exist + const beforeCount = await AclEntry.countDocuments({ + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + }); + expect(beforeCount).toBe(3); + + const result = await service.removeAllPermissions({ + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + }); + + expect(result.acknowledged).toBe(true); + expect(result.deletedCount).toBe(3); + + // Verify permissions are deleted + const afterCount = await AclEntry.countDocuments({ + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + }); + expect(afterCount).toBe(0); + }); + + test('should return result even when no permissions existed', async () => { + const newResourceId = new Types.ObjectId(); + + const result = await service.removeAllPermissions({ + resourceType: ResourceType.AGENT, + resourceId: newResourceId, + }); + + expect(result.acknowledged).toBe(true); + expect(result.deletedCount).toBe(0); + }); + + test('should not affect other resources permissions', async () => { + const otherResource = new Types.ObjectId(); + + // Grant permission to another resource + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: otherResource, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + await service.removeAllPermissions({ + resourceType: ResourceType.AGENT, + resourceId: resourceToDelete, + }); + + // Verify other resource still has permissions + const otherResourcePerms = await AclEntry.countDocuments({ + resourceType: ResourceType.AGENT, + resourceId: otherResource, + }); + expect(otherResourcePerms).toBe(1); + }); + }); + }); + + describe('checkPermission', () => { + const testResource = new Types.ObjectId(); + const groupResource = new Types.ObjectId(); + + beforeEach(async () => { + // User has VIEW on testResource + await service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.AGENT, + resourceId: testResource, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + // Group has EDIT on groupResource + await service.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: groupId, + resourceType: ResourceType.AGENT, + resourceId: groupResource, + accessRoleId: AccessRoleIds.AGENT_EDITOR, + grantedBy: grantedById, + }); + }); + + describe('validation', () => { + test('should throw error when requiredPermission is not a positive number', async () => { + await expect( + service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: testResource, + requiredPermission: 0, + }), + ).rejects.toThrow('requiredPermission must be a positive number'); + }); + + test('should throw error when requiredPermission is negative', async () => { + await expect( + service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: testResource, + requiredPermission: -1, + }), + ).rejects.toThrow('requiredPermission must be a positive number'); + }); + + test('should return false for invalid resource type (error is caught)', async () => { + // The service catches invalid resourceType errors and returns false + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: 'invalid' as ResourceType, + resourceId: testResource, + requiredPermission: 1, + }); + + expect(hasPermission).toBe(false); + }); + }); + + describe('permission scenarios', () => { + test('should return true when user has required permission', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: testResource, + requiredPermission: 1, // VIEW + }); + + expect(hasPermission).toBe(true); + }); + + test('should return false when user lacks required permission', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: testResource, + requiredPermission: 3, // EDIT + }); + + expect(hasPermission).toBe(false); + }); + + test('should return false when no principals found', async () => { + mockGetUserPrincipals.mockResolvedValue([]); + + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: testResource, + requiredPermission: 1, + }); + + expect(hasPermission).toBe(false); + }); + + test('should check permission via group membership', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ]); + + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: groupResource, + requiredPermission: 1, // VIEW (editor includes view) + }); + + expect(hasPermission).toBe(true); + }); + + test('should return false for non-existent resource', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + ]); + + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: new Types.ObjectId(), + requiredPermission: 1, + }); + + expect(hasPermission).toBe(false); + }); + + test('should pass role when provided for optimization', async () => { + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.ROLE, principalId: 'admin' }, + ]); + + await service.checkPermission({ + userId: userId.toString(), + role: 'admin', + resourceType: ResourceType.AGENT, + resourceId: testResource, + requiredPermission: 1, + }); + + expect(mockGetUserPrincipals).toHaveBeenCalledWith({ + userId: userId.toString(), + role: 'admin', + }); + }); + }); + + describe('public access', () => { + test('should check permission for public access', async () => { + const publicResource = new Types.ObjectId(); + + await service.grantPermission({ + principalType: PrincipalType.PUBLIC, + principalId: null, + resourceType: ResourceType.AGENT, + resourceId: publicResource, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }); + + mockGetUserPrincipals.mockResolvedValue([ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.PUBLIC }, + ]); + + const hasPermission = await service.checkPermission({ + userId: userId.toString(), + resourceType: ResourceType.AGENT, + resourceId: publicResource, + requiredPermission: 1, + }); + + expect(hasPermission).toBe(true); + }); + }); + }); + + describe('validateResourceType (via public methods)', () => { + test('should accept AGENT resource type', async () => { + mockGetUserPrincipals.mockResolvedValue([]); + + const result = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.AGENT, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + + test('should accept PROMPTGROUP resource type', async () => { + mockGetUserPrincipals.mockResolvedValue([]); + + const result = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + + test('should accept MCPSERVER resource type', async () => { + mockGetUserPrincipals.mockResolvedValue([]); + + const result = await service.findAccessibleResources({ + userId, + resourceType: ResourceType.MCPSERVER, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + + test('should return empty array for unknown resource type (error caught)', async () => { + // findAccessibleResources catches invalid resource type and returns empty array + const result = await service.findAccessibleResources({ + userId, + resourceType: 'unknown_type' as ResourceType, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + + test('should return empty array for empty string resource type (error caught)', async () => { + // findAccessibleResources catches invalid resource type and returns empty array + const result = await service.findAccessibleResources({ + userId, + resourceType: '' as ResourceType, + requiredPermissions: 1, + }); + + expect(result).toEqual([]); + }); + + test('should throw for invalid resource type in grantPermission', async () => { + // grantPermission throws directly for invalid resource type + await expect( + service.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: 'unknown_type' as ResourceType, + resourceId, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + grantedBy: grantedById, + }), + ).rejects.toThrow('Invalid resourceType: unknown_type'); + }); + + test('should throw for empty string resource type in removeAllPermissions', async () => { + // removeAllPermissions throws directly for invalid resource type + await expect( + service.removeAllPermissions({ + resourceType: '' as ResourceType, + resourceId, + }), + ).rejects.toThrow('Invalid resourceType:'); + }); + }); +}); diff --git a/packages/api/src/acl/accessControlService.ts b/packages/api/src/acl/accessControlService.ts new file mode 100644 index 0000000000..98bc939c24 --- /dev/null +++ b/packages/api/src/acl/accessControlService.ts @@ -0,0 +1,360 @@ +import { Types, ClientSession, DeleteResult } from 'mongoose'; +import { AllMethods, IAclEntry, createMethods, logger } from '@librechat/data-schemas'; +import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider'; + +export class AccessControlService { + private _dbMethods: AllMethods; + private _aclModel; + constructor(mongoose: typeof import('mongoose')) { + this._dbMethods = createMethods(mongoose); + this._aclModel = mongoose.models.AclEntry; + } + + /** + * Grant a permission to a principal for a resource using a role + * @param {Object} params - Parameters for granting role-based permission + * @param {string} params.principalType - PrincipalType.USER, PrincipalType.GROUP, or PrincipalType.PUBLIC + * @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for PrincipalType.PUBLIC) + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @param {string} params.accessRoleId - The ID of the role (e.g., AccessRoleIds.AGENT_VIEWER, AccessRoleIds.AGENT_EDITOR) + * @param {Types.ObjectId} params.grantedBy - User ID granting the permission + * @param {ClientSession} [params.session] - Optional MongoDB session for transactions + * @returns {Promise} The created or updated ACL entry + */ + public async grantPermission(args: { + principalType: PrincipalType; + principalId: string | Types.ObjectId | null; + resourceType: string; + resourceId: string | Types.ObjectId; + accessRoleId: AccessRoleIds; + + grantedBy: string | Types.ObjectId; + session?: ClientSession; + roleId?: string | Types.ObjectId; + }): Promise { + const { + principalType, + principalId, + resourceType, + resourceId, + accessRoleId, + grantedBy, + session, + } = args; + try { + if (!Object.values(PrincipalType).includes(principalType)) { + throw new Error(`Invalid principal type: ${principalType}`); + } + + if (principalType !== PrincipalType.PUBLIC && !principalId) { + throw new Error('Principal ID is required for user, group, and role principals'); + } + + // Validate principalId based on type + if (principalId && principalType === PrincipalType.ROLE) { + // Role IDs are strings (role names) + if (typeof principalId !== 'string' || principalId.trim().length === 0) { + throw new Error(`Invalid role ID: ${principalId}`); + } + } else if ( + principalType && + principalType !== PrincipalType.PUBLIC && + (!principalId || !Types.ObjectId.isValid(principalId)) + ) { + // User and Group IDs must be valid ObjectIds + throw new Error(`Invalid principal ID: ${principalId}`); + } + + if (!resourceId || !Types.ObjectId.isValid(resourceId)) { + throw new Error(`Invalid resource ID: ${resourceId}`); + } + + this.validateResourceType(resourceType as ResourceType); + + // Get the role to determine permission bits + const role = await this._dbMethods.findRoleByIdentifier(accessRoleId); + if (!role) { + throw new Error(`Role ${accessRoleId} not found`); + } + + // Ensure the role is for the correct resource type + if (role.resourceType !== resourceType) { + throw new Error( + `Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`, + ); + } + return await this._dbMethods.grantPermission( + principalType, + principalId, + resourceType, + resourceId, + role.permBits, + grantedBy, + session, + role._id, + ); + } catch (error) { + logger.error( + `[PermissionService.grantPermission] Error: ${error instanceof 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 + * @param {string | Types.ObjectId} params.userId - The ID of the user + * @param {string} [params.role] - Optional user role (if not provided, will query from DB) + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT) + * @returns {Promise} Array of resource IDs + */ + public async findAccessibleResources({ + userId, + role, + resourceType, + requiredPermissions, + }: { + userId: string | Types.ObjectId; + role?: string; + resourceType: string; + requiredPermissions: number; + }): Promise { + try { + if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) { + throw new Error('requiredPermissions must be a positive number'); + } + + this.validateResourceType(resourceType as ResourceType); + + // Get all principals for the user (user + groups + public) + const principalsList = await this._dbMethods.getUserPrincipals({ userId, role }); + + if (principalsList.length === 0) { + return []; + } + return await this._dbMethods.findAccessibleResources( + principalsList, + resourceType, + requiredPermissions, + ); + } catch (error) { + if (error instanceof Error) { + logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermissions must be')) { + throw error; + } + } + return []; + } + } + + /** + * Find all publicly accessible resources of a specific type + * @param {Object} params - Parameters for finding publicly accessible resources + * @param {ResourceType} params.resourceType - Type of resource (e.g., 'agent') + * @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT) + * @returns {Promise} Array of resource IDs + */ + public async findPubliclyAccessibleResources({ + resourceType, + requiredPermissions, + }: { + resourceType: ResourceType; + requiredPermissions: number; + }): Promise { + try { + if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) { + throw new Error('requiredPermissions must be a positive number'); + } + + this.validateResourceType(resourceType); + + // Find all public ACL entries where the public principal has at least the required permission bits + const entries = await this._aclModel + .find({ + principalType: PrincipalType.PUBLIC, + resourceType, + permBits: { $bitsAllSet: requiredPermissions }, + }) + .distinct('resourceId'); + + return entries; + } catch (error) { + if (error instanceof Error) { + logger.error(`[PermissionService.findPubliclyAccessibleResources] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermissions must be')) { + throw error; + } + } + return []; + } + } + + /** + * 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} params.resourceIds - Array of resource IDs + * @returns {Promise>} Map of resourceId string → permission bits + * @throws {Error} If resourceType is invalid + */ + public async getResourcePermissionsMap({ + userId, + role, + resourceType, + resourceIds, + }: { + userId: string | Types.ObjectId; + role: string; + resourceType: ResourceType; + resourceIds: (string | Types.ObjectId)[]; + }): Promise> { + // Validate resource type - throw on invalid type + this.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 this._dbMethods.getUserPrincipals({ userId, role }); + + // Use batch method from aclEntry + const permissionsMap = await this._dbMethods.getEffectivePermissionsForResources( + principals, + resourceType, + resourceIds, + ); + + logger.debug( + `[PermissionService.getResourcePermissionsMap] Computed permissions for ${resourceIds.length} resources, ${permissionsMap.size} have permissions`, + ); + + return permissionsMap; + } catch (error) { + if (error instanceof Error) { + logger.error( + `[PermissionService.getResourcePermissionsMap] Error: ${error.message}`, + error, + ); + } + throw error; + } + } + + /** + * Remove all permissions for a resource (cleanup when resource is deleted) + * @param {Object} params - Parameters for removing all permissions + * @param {string} params.resourceType - Type of resource (e.g., 'agent', 'prompt') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @returns {Promise} Result of the deletion operation + */ + public async removeAllPermissions({ + resourceType, + resourceId, + }: { + resourceType: ResourceType; + resourceId: string | Types.ObjectId; + }): Promise { + try { + this.validateResourceType(resourceType); + + if (!resourceId || !Types.ObjectId.isValid(resourceId)) { + throw new Error(`Invalid resource ID: ${resourceId}`); + } + + const result = await this._aclModel.deleteMany({ + resourceType, + resourceId, + }); + + return result; + } catch (error) { + if (error instanceof Error) { + logger.error(`[PermissionService.removeAllPermissions] Error: ${error.message}`); + } + throw error; + } + } + + /** + * Check if a user has specific permission bits on a resource + * @param {Object} params - Parameters for checking permissions + * @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user + * @param {string} [params.role] - Optional user role (if not provided, will query from DB) + * @param {string} params.resourceType - Type of resource (e.g., 'agent') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT) + * @returns {Promise} Whether the user has the required permission bits + */ + public async checkPermission({ + userId, + role, + resourceType, + resourceId, + requiredPermission, + }: { + userId: string; + role?: string; + resourceType: ResourceType; + resourceId: string | Types.ObjectId; + requiredPermission: number; + }): Promise { + try { + if (typeof requiredPermission !== 'number' || requiredPermission < 1) { + throw new Error('requiredPermission must be a positive number'); + } + + this.validateResourceType(resourceType); + + // Get all principals for the user (user + groups + public) + const principals = await this._dbMethods.getUserPrincipals({ userId, role }); + + if (principals.length === 0) { + return false; + } + + return await this._dbMethods.hasPermission( + principals, + resourceType, + resourceId, + requiredPermission, + ); + } catch (error) { + if (error instanceof Error) { + logger.error(`[PermissionService.checkPermission] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermission must be')) { + throw error; + } + } + return false; + } + } + + /** + * Validates that the resourceType is one of the supported enum values + * @param {string} resourceType - The resource type to validate + * @throws {Error} If resourceType is not valid + */ + private validateResourceType(resourceType: ResourceType): void { + const validTypes = Object.values(ResourceType); + if (!validTypes.includes(resourceType)) { + throw new Error( + `Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`, + ); + } + } +} diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index 6eeb70d7d7..9890ad5299 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -74,6 +74,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -103,6 +108,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); @@ -182,6 +192,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -211,6 +226,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); @@ -290,6 +310,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -319,6 +344,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); @@ -411,6 +441,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -440,6 +475,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); @@ -519,6 +559,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -548,6 +593,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); @@ -630,6 +680,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -653,6 +708,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); @@ -751,6 +811,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + }, }; const expectedPermissionsForAdmin = { @@ -777,6 +842,11 @@ describe('updateInterfacePermissions - permissions', () => { }, [PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true }, [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }; expect(mockUpdateAccessPermissions).toHaveBeenCalledTimes(2); diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index eaaa5c9705..eb93cd4e7d 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -41,6 +41,8 @@ function hasExplicitConfig( return interfaceConfig?.fileSearch !== undefined; case PermissionTypes.FILE_CITATIONS: return interfaceConfig?.fileCitations !== undefined; + case PermissionTypes.MCP_SERVERS: + return interfaceConfig?.mcpServers !== undefined; default: return false; } @@ -256,6 +258,23 @@ export async function updateInterfacePermissions({ defaults.fileCitations, ), }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: getPermissionValue( + loadedInterface.mcpServers?.use, + defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.USE], + defaults.mcpServers?.use, + ), + [Permissions.CREATE]: getPermissionValue( + loadedInterface.mcpServers?.create, + defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.CREATE], + defaults.mcpServers?.create, + ), + [Permissions.SHARE]: getPermissionValue( + loadedInterface.mcpServers?.share, + defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE], + defaults.mcpServers?.share, + ), + }, }; // Check and add each permission type if needed diff --git a/packages/api/src/mcp/ConnectionsRepository.ts b/packages/api/src/mcp/ConnectionsRepository.ts index 4e2ad5bc9d..e2c48c88ab 100644 --- a/packages/api/src/mcp/ConnectionsRepository.ts +++ b/packages/api/src/mcp/ConnectionsRepository.ts @@ -50,12 +50,12 @@ export class ConnectionsRepository { } if (existingConnection) { // Check if config was cached/updated since connection was created - if (serverConfig.lastUpdatedAt && existingConnection.isStale(serverConfig.lastUpdatedAt)) { + if (serverConfig.updatedAt && existingConnection.isStale(serverConfig.updatedAt)) { logger.info( `${this.prefix(serverName)} Existing connection for ${serverName} is outdated. Recreating a new connection.`, { connectionCreated: new Date(existingConnection.createdAt).toISOString(), - configCachedAt: new Date(serverConfig.lastUpdatedAt).toISOString(), + configCachedAt: new Date(serverConfig.updatedAt).toISOString(), }, ); diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index a775b4d78d..1b85b69eac 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -79,7 +79,7 @@ export abstract class UserConnectionManager { } connection = undefined; // Force creation of a new connection } else if (connection) { - if (!config || (config.lastUpdatedAt && connection.isStale(config.lastUpdatedAt))) { + if (!config || (config.updatedAt && connection.isStale(config.updatedAt))) { if (config) { logger.info( `[MCP][User: ${userId}][${serverName}] Config was updated, disconnecting stale connection`, diff --git a/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts b/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts index bc382ebe39..e722b38375 100644 --- a/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts +++ b/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts @@ -145,10 +145,10 @@ describe('ConnectionsRepository', () => { isStale: jest.fn().mockReturnValue(true), } as unknown as jest.Mocked; - // Update server config with lastUpdatedAt timestamp + // Update server config with updatedAt timestamp const configWithCachedAt = { ...mockServerConfigs.server1, - lastUpdatedAt: configCachedAt, + updatedAt: configCachedAt, }; mockRegistry.getServerConfig.mockResolvedValueOnce(configWithCachedAt); @@ -156,7 +156,7 @@ describe('ConnectionsRepository', () => { const result = await repository.get('server1'); - // Verify stale check was called with the config's lastUpdatedAt timestamp + // Verify stale check was called with the config's updatedAt timestamp expect(staleConnection.isStale).toHaveBeenCalledWith(configCachedAt); // Verify old connection was disconnected @@ -190,10 +190,10 @@ describe('ConnectionsRepository', () => { isStale: jest.fn().mockReturnValue(false), } as unknown as jest.Mocked; - // Update server config with lastUpdatedAt timestamp + // Update server config with updatedAt timestamp const configWithCachedAt = { ...mockServerConfigs.server1, - lastUpdatedAt: configCachedAt, + updatedAt: configCachedAt, }; mockRegistry.getServerConfig.mockResolvedValueOnce(configWithCachedAt); diff --git a/packages/api/src/mcp/registry/MCPServersInitializer.ts b/packages/api/src/mcp/registry/MCPServersInitializer.ts index 15f6a42f4e..38ea986e4e 100644 --- a/packages/api/src/mcp/registry/MCPServersInitializer.ts +++ b/packages/api/src/mcp/registry/MCPServersInitializer.ts @@ -59,12 +59,12 @@ export class MCPServersInitializer { /** Initializes a single server with all its metadata and adds it to appropriate collections */ public static async initializeServer(serverName: string, rawConfig: t.MCPOptions): Promise { try { - const config = await MCPServersRegistry.getInstance().addServer( + const result = await MCPServersRegistry.getInstance().addServer( serverName, rawConfig, 'CACHE', ); - MCPServersInitializer.logParsedConfig(serverName, config); + MCPServersInitializer.logParsedConfig(serverName, result.config); } catch (error) { logger.error(`${MCPServersInitializer.prefix(serverName)} Failed to initialize:`, error); } diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index b12afdeeb5..cf44041f48 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -76,11 +76,10 @@ export class MCPServersRegistry { config: t.MCPOptions, storageLocation: 'CACHE' | 'DB', userId?: string, - ): Promise { + ): Promise { const configRepo = this.getConfigRepository(storageLocation); const parsedConfig = await MCPServerInspector.inspect(serverName, config); - await configRepo.add(serverName, parsedConfig, userId); - return parsedConfig; + return await configRepo.add(serverName, parsedConfig, userId); } public async updateServer( @@ -88,10 +87,11 @@ export class MCPServersRegistry { config: t.MCPOptions, storageLocation: 'CACHE' | 'DB', userId?: string, - ): Promise { + ): Promise { const configRepo = this.getConfigRepository(storageLocation); const parsedConfig = await MCPServerInspector.inspect(serverName, config); await configRepo.update(serverName, parsedConfig, userId); + return parsedConfig; } // TODO: This is currently used to determine if a server requires OAuth. However, this info can diff --git a/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts b/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts index 9f8b2e25fe..1c913dd1a3 100644 --- a/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts +++ b/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts @@ -1,10 +1,10 @@ -import { ParsedServerConfig } from '~/mcp/types'; +import { ParsedServerConfig, AddServerResult } from '~/mcp/types'; /** * Interface for future DB implementation */ export interface IServerConfigsRepositoryInterface { - add(serverName: string, config: ParsedServerConfig, userId?: string): Promise; + add(serverName: string, config: ParsedServerConfig, userId?: string): Promise; //ACL Entry check if update is possible update(serverName: string, config: ParsedServerConfig, userId?: string): Promise; diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.cache_integration.spec.ts b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.cache_integration.spec.ts index 4119b17ea0..908817a84c 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.cache_integration.spec.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.cache_integration.spec.ts @@ -8,6 +8,18 @@ jest.mock('~/cluster', () => ({ isLeader: jest.fn().mockResolvedValue(true), })); +// Mock ServerConfigsDB to avoid needing MongoDB for cache integration tests +jest.mock('../db/ServerConfigsDB', () => ({ + ServerConfigsDB: jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue({}), + add: jest.fn().mockResolvedValue({ config: {}, isNew: true }), + update: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + reset: jest.fn().mockResolvedValue(undefined), + })), +})); + describe('MCPServersInitializer Redis Integration Tests', () => { let MCPServersInitializer: typeof import('../MCPServersInitializer').MCPServersInitializer; let MCPServersRegistry: typeof import('../MCPServersRegistry').MCPServersRegistry; diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.cache_integration.spec.ts b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.cache_integration.spec.ts index 7e0accf687..c2a3a0ae09 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.cache_integration.spec.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.cache_integration.spec.ts @@ -1,8 +1,22 @@ import { expect } from '@playwright/test'; -import { MongoMemoryServer } from 'mongodb-memory-server'; import type * as t from '~/mcp/types'; import type { MCPServersRegistry as MCPServersRegistryType } from '../MCPServersRegistry'; +// Mock ServerConfigsDB to avoid needing MongoDB for cache integration tests +jest.mock('../db/ServerConfigsDB', () => ({ + ServerConfigsDB: jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(undefined), + getAll: jest.fn().mockResolvedValue({}), + add: jest.fn().mockResolvedValue({ + serverName: 'mock-server', + config: {} as t.ParsedServerConfig, + }), + update: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + reset: jest.fn().mockResolvedValue(undefined), + })), +})); + /** * Integration tests for MCPServersRegistry using Redis-backed cache. * For unit tests using in-memory cache, see MCPServersRegistry.test.ts @@ -18,7 +32,6 @@ describe('MCPServersRegistry Redis Integration Tests', () => { let LeaderElection: typeof import('~/cluster/LeaderElection').LeaderElection; let leaderInstance: InstanceType; let MCPServerInspector: typeof import('../MCPServerInspector').MCPServerInspector; - let mongoServer: MongoMemoryServer; const testParsedConfig: t.ParsedServerConfig = { type: 'stdio', @@ -60,21 +73,12 @@ describe('MCPServersRegistry Redis Integration Tests', () => { const leaderElectionModule = await import('~/cluster/LeaderElection'); const inspectorModule = await import('../MCPServerInspector'); const mongoose = await import('mongoose'); - const { userSchema } = await import('@librechat/data-schemas'); MCPServersRegistry = registryModule.MCPServersRegistry; keyvRedisClient = redisClients.keyvRedisClient; LeaderElection = leaderElectionModule.LeaderElection; MCPServerInspector = inspectorModule.MCPServerInspector; - // Set up MongoDB with MongoMemoryServer for db methods - mongoServer = await MongoMemoryServer.create(); - const mongoUri = mongoServer.getUri(); - if (!mongoose.default.models.User) { - mongoose.default.model('User', userSchema); - } - await mongoose.default.connect(mongoUri); - // Reset singleton and create new instance with mongoose (MCPServersRegistry as unknown as { instance: undefined }).instance = undefined; MCPServersRegistry.createInstance(mongoose.default); @@ -135,11 +139,6 @@ describe('MCPServersRegistry Redis Integration Tests', () => { // Close Redis connection if (keyvRedisClient?.isOpen) await keyvRedisClient.disconnect(); - - // Close MongoDB connection and stop memory server - const mongoose = await import('mongoose'); - await mongoose.default.disconnect(); - if (mongoServer) await mongoServer.stop(); }); // Tests for the old privateServersCache API have been removed diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts index 54956ec340..9db43c4f87 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts @@ -49,7 +49,7 @@ describe('MCPServersRegistry', () => { }, }, }, - lastUpdatedAt: FIXED_TIME, + updatedAt: FIXED_TIME, }; beforeAll(() => { jest.useFakeTimers(); diff --git a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts new file mode 100644 index 0000000000..ea2dc48285 --- /dev/null +++ b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts @@ -0,0 +1,844 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { + AccessRoleIds, + PermissionBits, + PrincipalType, + PrincipalModel, + ResourceType, +} from 'librechat-data-provider'; +import { createModels, createMethods, RoleBits } from '@librechat/data-schemas'; +import { ServerConfigsDB } from '../db/ServerConfigsDB'; +import type { ParsedServerConfig } from '~/mcp/types'; + +// Mock the logger +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + }, +})); + +let mongoServer: MongoMemoryServer; +let serverConfigsDB: ServerConfigsDB; + +// Test data helpers +const createSSEConfig = ( + title?: string, + description?: string, + oauth?: { client_secret?: string; client_id?: string }, +): ParsedServerConfig => ({ + type: 'sse', + url: 'https://example.com/mcp', + ...(title && { title }), + ...(description && { description }), + ...(oauth && { oauth }), +}); + +let dbMethods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + + // Initialize all models + createModels(mongoose); + + // Create methods and seed default roles + dbMethods = createMethods(mongoose); + await dbMethods.seedDefaultRoles(); + + serverConfigsDB = new ServerConfigsDB(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + // Clear collections except AccessRole + await mongoose.models.MCPServer.deleteMany({}); + await mongoose.models.Agent.deleteMany({}); + await mongoose.models.AclEntry.deleteMany({}); +}); + +describe('ServerConfigsDB', () => { + const userId = new mongoose.Types.ObjectId().toString(); + const userId2 = new mongoose.Types.ObjectId().toString(); + + describe('constructor', () => { + it('should throw error when mongoose is not provided', () => { + expect(() => new ServerConfigsDB(null as unknown as typeof mongoose)).toThrow( + 'ServerConfigsDB requires mongoose instance', + ); + }); + + it('should create instance when mongoose is provided', () => { + const instance = new ServerConfigsDB(mongoose); + expect(instance).toBeInstanceOf(ServerConfigsDB); + }); + }); + + describe('add()', () => { + it('should throw error when userId is not provided', async () => { + await expect(serverConfigsDB.add('test-server', createSSEConfig('Test'))).rejects.toThrow( + 'User ID is required to create a database-stored MCP server', + ); + }); + + it('should create server and return AddServerResult with generated serverName', async () => { + const config = createSSEConfig('My Test Server', 'A test server'); + const result = await serverConfigsDB.add('temp-name', config, userId); + + expect(result).toBeDefined(); + expect(result.serverName).toBe('my-test-server'); + expect(result.config).toMatchObject({ + type: 'sse', + url: 'https://example.com/mcp', + title: 'My Test Server', + description: 'A test server', + }); + expect(result.config.dbId).toBeDefined(); + }); + + it('should grant owner ACL to the user', async () => { + const config = createSSEConfig('ACL Test Server'); + const result = await serverConfigsDB.add('temp-name', config, userId); + + // Verify ACL entry was created + const aclEntry = await mongoose.models.AclEntry.findOne({ + principalType: PrincipalType.USER, + principalId: new mongoose.Types.ObjectId(userId), + resourceType: ResourceType.MCPSERVER, + }); + + expect(aclEntry).toBeDefined(); + expect(aclEntry!.resourceId.toString()).toBe(result.config.dbId); + // OWNER role has VIEW | EDIT | DELETE | SHARE = 15 + expect(aclEntry!.permBits).toBe(RoleBits.OWNER); + }); + + it('should include dbId and updatedAt in returned config', async () => { + const config = createSSEConfig('Metadata Test'); + const result = await serverConfigsDB.add('temp-name', config, userId); + + expect(result.config.dbId).toBeDefined(); + expect(typeof result.config.dbId).toBe('string'); + expect(result.config.updatedAt).toBeDefined(); + expect(typeof result.config.updatedAt).toBe('number'); + }); + }); + + describe('update()', () => { + it('should throw error when userId is not provided', async () => { + await expect(serverConfigsDB.update('test-server', createSSEConfig('Test'))).rejects.toThrow( + 'User ID is required to update a database-stored MCP server', + ); + }); + + it('should update server config', async () => { + const config = createSSEConfig('Original Title', 'Original description'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + const updatedConfig = createSSEConfig('Original Title', 'Updated description'); + await serverConfigsDB.update(created.serverName, updatedConfig, userId); + + const retrieved = await serverConfigsDB.get(created.serverName, userId); + expect(retrieved?.description).toBe('Updated description'); + }); + + it('should preserve oauth.client_secret when not provided in update', async () => { + const config = createSSEConfig('OAuth Server', 'Test', { + client_id: 'my-client-id', + client_secret: 'super-secret-key', + }); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Update without client_secret + const updatedConfig = createSSEConfig('OAuth Server', 'Updated description', { + client_id: 'my-client-id', + // client_secret not provided + }); + await serverConfigsDB.update(created.serverName, updatedConfig, userId); + + // Verify the secret is preserved + const MCPServer = mongoose.models.MCPServer; + const server = await MCPServer.findOne({ serverName: created.serverName }); + expect(server?.config?.oauth?.client_secret).toBe('super-secret-key'); + }); + + it('should allow updating oauth.client_secret when explicitly provided', async () => { + const config = createSSEConfig('OAuth Server 2', 'Test', { + client_id: 'my-client-id', + client_secret: 'old-secret', + }); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Update with new client_secret + const updatedConfig = createSSEConfig('OAuth Server 2', 'Updated', { + client_id: 'my-client-id', + client_secret: 'new-secret', + }); + await serverConfigsDB.update(created.serverName, updatedConfig, userId); + + // Verify the secret is updated + const MCPServer = mongoose.models.MCPServer; + const server = await MCPServer.findOne({ serverName: created.serverName }); + expect(server?.config?.oauth?.client_secret).toBe('new-secret'); + }); + }); + + describe('remove()', () => { + it('should delete server from database', async () => { + const config = createSSEConfig('Delete Test'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + await serverConfigsDB.remove(created.serverName, userId); + + const MCPServer = mongoose.models.MCPServer; + const server = await MCPServer.findOne({ serverName: created.serverName }); + expect(server).toBeNull(); + }); + + it('should remove all ACL entries for the server', async () => { + const config = createSSEConfig('ACL Delete Test'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Verify ACL exists before deletion + let aclEntries = await mongoose.models.AclEntry.find({ + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created.config.dbId!), + }); + expect(aclEntries.length).toBeGreaterThan(0); + + await serverConfigsDB.remove(created.serverName, userId); + + // Verify ACL entries are removed + aclEntries = await mongoose.models.AclEntry.find({ + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created.config.dbId!), + }); + expect(aclEntries.length).toBe(0); + }); + + it('should handle non-existent server gracefully', async () => { + // Should not throw + await expect(serverConfigsDB.remove('non-existent-server', userId)).resolves.toBeUndefined(); + }); + }); + + describe('get()', () => { + describe('public access (no userId)', () => { + it('should return undefined for non-public server without userId', async () => { + const config = createSSEConfig('Private Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + const result = await serverConfigsDB.get(created.serverName); + expect(result).toBeUndefined(); + }); + + it('should return server when publicly shared', async () => { + const config = createSSEConfig('Public Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Grant public access + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.PUBLIC, + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created.config.dbId!), + permBits: PermissionBits.VIEW, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.get(created.serverName); + expect(result).toBeDefined(); + expect(result?.title).toBe('Public Server'); + }); + + it('should return server with consumeOnly when accessible via public agent', async () => { + const config = createSSEConfig('Agent MCP Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Create an agent that has this MCP server + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'test-agent-id', + name: 'Test Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + // Grant public access to the agent + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.PUBLIC, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.get(created.serverName); + expect(result).toBeDefined(); + expect(result?.consumeOnly).toBe(true); + }); + }); + + describe('user direct access', () => { + it('should return server when user has direct VIEW permission', async () => { + const config = createSSEConfig('Direct Access Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // The owner should have access + const result = await serverConfigsDB.get(created.serverName, userId); + expect(result).toBeDefined(); + expect(result?.title).toBe('Direct Access Server'); + expect(result?.consumeOnly).toBeUndefined(); + }); + + it('should return undefined when user has no permission', async () => { + const config = createSSEConfig('Restricted Server'); + await serverConfigsDB.add('temp-name', config, userId); + + // Different user without access + const result = await serverConfigsDB.get('restricted-server', userId2); + expect(result).toBeUndefined(); + }); + + it('should return server when user is granted VIEW permission', async () => { + const config = createSSEConfig('Shared Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Grant VIEW permission to userId2 + const role = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.MCPSERVER_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created.config.dbId!), + permBits: PermissionBits.VIEW, + roleId: role!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeDefined(); + expect(result?.title).toBe('Shared Server'); + }); + }); + + describe('agent-based access (consumeOnly)', () => { + it('should return server with consumeOnly when user has access via agent', async () => { + const config = createSSEConfig('Agent Accessible Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Create an agent with this MCP server + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'agent-for-user2', + name: 'Agent for User 2', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + // Grant agent access to userId2 + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeDefined(); + expect(result?.consumeOnly).toBe(true); + expect(result?.title).toBe('Agent Accessible Server'); + }); + + it('should prefer direct access over agent access (no consumeOnly)', async () => { + const config = createSSEConfig('Both Access Server'); + const created = await serverConfigsDB.add('temp-name', config, userId); + + // Create an agent with this MCP server + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'agent-both-access', + name: 'Agent Both Access', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + // Grant userId2 both direct MCP access and agent access + const mcpRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.MCPSERVER_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created.config.dbId!), + permBits: PermissionBits.VIEW, + roleId: mcpRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + // Direct access should take precedence (no consumeOnly) + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeDefined(); + expect(result?.consumeOnly).toBeUndefined(); + }); + }); + + it('should return undefined for non-existent server', async () => { + const result = await serverConfigsDB.get('non-existent-server', userId); + expect(result).toBeUndefined(); + }); + }); + + describe('getAll()', () => { + describe('public access (no userId)', () => { + it('should return empty object when no public servers exist', async () => { + const config = createSSEConfig('Private Server'); + await serverConfigsDB.add('temp-name', config, userId); + + const result = await serverConfigsDB.getAll(); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('should return only publicly shared servers', async () => { + const config1 = createSSEConfig('Public Server 1'); + const config2 = createSSEConfig('Private Server'); + const created1 = await serverConfigsDB.add('temp1', config1, userId); + await serverConfigsDB.add('temp2', config2, userId); + + // Make first server public + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.PUBLIC, + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created1.config.dbId!), + permBits: PermissionBits.VIEW, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.getAll(); + expect(Object.keys(result)).toHaveLength(1); + expect(result['public-server-1']).toBeDefined(); + }); + }); + + describe('user access', () => { + it('should return servers directly accessible by user', async () => { + const config1 = createSSEConfig('User Server 1'); + const config2 = createSSEConfig('User Server 2'); + await serverConfigsDB.add('temp1', config1, userId); + await serverConfigsDB.add('temp2', config2, userId); + + // Create server by different user (not accessible) + await serverConfigsDB.add('temp3', createSSEConfig('Other User Server'), userId2); + + const result = await serverConfigsDB.getAll(userId); + expect(Object.keys(result)).toHaveLength(2); + expect(result['user-server-1']).toBeDefined(); + expect(result['user-server-2']).toBeDefined(); + expect(result['other-user-server']).toBeUndefined(); + }); + + it('should include agent-accessible servers with consumeOnly flag', async () => { + const config1 = createSSEConfig('Direct Server'); + const config2 = createSSEConfig('Agent Only Server'); + await serverConfigsDB.add('temp1', config1, userId); + const created2 = await serverConfigsDB.add('temp2', config2, userId); + + // Create an agent with second MCP server, accessible by userId2 + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'getall-agent', + name: 'GetAll Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created2.serverName], + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.getAll(userId2); + expect(Object.keys(result)).toHaveLength(1); + expect(result['agent-only-server']).toBeDefined(); + expect(result['agent-only-server'].consumeOnly).toBe(true); + }); + + it('should deduplicate servers with both direct and agent access', async () => { + const config = createSSEConfig('Dedup Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + // Create an agent with this MCP server + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'dedup-agent', + name: 'Dedup Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + // Grant userId2 both direct MCP access and agent access + const mcpRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.MCPSERVER_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.MCPSERVER, + resourceId: new mongoose.Types.ObjectId(created.config.dbId!), + permBits: PermissionBits.VIEW, + roleId: mcpRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.getAll(userId2); + // Should only have one entry (deduplicated) + expect(Object.keys(result)).toHaveLength(1); + // Direct access takes precedence - no consumeOnly + expect(result['dedup-server']).toBeDefined(); + expect(result['dedup-server'].consumeOnly).toBeUndefined(); + }); + + it('should merge servers from multiple agents', async () => { + const config1 = createSSEConfig('Agent1 Server'); + const config2 = createSSEConfig('Agent2 Server'); + const created1 = await serverConfigsDB.add('temp1', config1, userId); + const created2 = await serverConfigsDB.add('temp2', config2, userId); + + // Create two agents, each with a different MCP server + const Agent = mongoose.models.Agent; + const agent1 = await Agent.create({ + id: 'multi-agent-1', + name: 'Multi Agent 1', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created1.serverName], + }); + + const agent2 = await Agent.create({ + id: 'multi-agent-2', + name: 'Multi Agent 2', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created2.serverName], + }); + + // Grant userId2 access to both agents + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + + await mongoose.models.AclEntry.create([ + { + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent1._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }, + { + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent2._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }, + ]); + + const result = await serverConfigsDB.getAll(userId2); + expect(Object.keys(result)).toHaveLength(2); + expect(result['agent1-server']?.consumeOnly).toBe(true); + expect(result['agent2-server']?.consumeOnly).toBe(true); + }); + }); + }); + + describe('hasAccessViaAgent() - private method integration', () => { + it('should return false when no agents exist', async () => { + const config = createSSEConfig('No Agent Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + // Access via get() which uses hasAccessViaAgent internally + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeUndefined(); + }); + + it('should return false when agent has MCP but user has no agent access', async () => { + const config = createSSEConfig('Inaccessible Agent Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + // Create an agent with this MCP server but no ACL for userId2 + const Agent = mongoose.models.Agent; + await Agent.create({ + id: 'inaccessible-agent', + name: 'Inaccessible Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeUndefined(); + }); + + it('should return true when user has VIEW access to agent with the MCP server', async () => { + const config = createSSEConfig('Accessible Agent Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'accessible-agent', + name: 'Accessible Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeDefined(); + expect(result?.consumeOnly).toBe(true); + }); + + it('should handle multiple agents - one accessible, one not', async () => { + const config = createSSEConfig('Multi Agent Access Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + const Agent = mongoose.models.Agent; + + // Agent 1: has MCP server but no user access + await Agent.create({ + id: 'no-access-agent', + name: 'No Access Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + // Agent 2: has MCP server AND user has access + const accessibleAgent = await Agent.create({ + id: 'has-access-agent', + name: 'Has Access Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [created.serverName], + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: accessibleAgent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeDefined(); + expect(result?.consumeOnly).toBe(true); + }); + }); + + describe('reset()', () => { + it('should be a no-op and not throw', async () => { + // Create a server first + const config = createSSEConfig('Reset Test'); + await serverConfigsDB.add('temp', config, userId); + + // Reset should complete without error + await expect(serverConfigsDB.reset()).resolves.toBeUndefined(); + + // Server should still exist (reset is no-op for DB storage) + const result = await serverConfigsDB.get('reset-test', userId); + expect(result).toBeDefined(); + }); + }); + + describe('mapDBServerToParsedConfig()', () => { + it('should include dbId from _id', async () => { + const config = createSSEConfig('Map Test'); + const created = await serverConfigsDB.add('temp', config, userId); + + expect(created.config.dbId).toBeDefined(); + expect(typeof created.config.dbId).toBe('string'); + expect(mongoose.Types.ObjectId.isValid(created.config.dbId!)).toBe(true); + }); + + it('should include updatedAt as timestamp', async () => { + const config = createSSEConfig('Timestamp Test'); + const created = await serverConfigsDB.add('temp', config, userId); + + expect(created.config.updatedAt).toBeDefined(); + expect(typeof created.config.updatedAt).toBe('number'); + expect(created.config.updatedAt).toBeLessThanOrEqual(Date.now()); + }); + }); + + describe('edge cases', () => { + it('should handle server with empty mcpServerNames in agent', async () => { + const config = createSSEConfig('Edge Case Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + // Create an agent with empty mcpServerNames + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'empty-mcp-agent', + name: 'Empty MCP Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + mcpServerNames: [], // Empty array + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + // Should not find the server via agent (empty mcpServerNames) + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeUndefined(); + }); + + it('should handle agent without mcpServerNames field', async () => { + const config = createSSEConfig('No Field Server'); + const created = await serverConfigsDB.add('temp', config, userId); + + // Create an agent without mcpServerNames field (uses default) + const Agent = mongoose.models.Agent; + const agent = await Agent.create({ + id: 'no-field-agent', + name: 'No Field Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(userId), + // mcpServerNames not specified - should default to [] + }); + + const agentRole = await mongoose.models.AccessRole.findOne({ + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + await mongoose.models.AclEntry.create({ + principalType: PrincipalType.USER, + principalModel: PrincipalModel.USER, + principalId: new mongoose.Types.ObjectId(userId2), + resourceType: ResourceType.AGENT, + resourceId: agent._id, + permBits: PermissionBits.VIEW, + roleId: agentRole!._id, + grantedBy: new mongoose.Types.ObjectId(userId), + }); + + // Should not find the server via agent + const result = await serverConfigsDB.get(created.serverName, userId2); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/api/src/mcp/registry/cache/ServerConfigsCacheInMemory.ts b/packages/api/src/mcp/registry/cache/ServerConfigsCacheInMemory.ts index 0e03bf8dff..384c477756 100644 --- a/packages/api/src/mcp/registry/cache/ServerConfigsCacheInMemory.ts +++ b/packages/api/src/mcp/registry/cache/ServerConfigsCacheInMemory.ts @@ -1,4 +1,4 @@ -import { ParsedServerConfig } from '~/mcp/types'; +import { ParsedServerConfig, AddServerResult } from '~/mcp/types'; /** * In-memory implementation of MCP server configurations cache for single-instance deployments. @@ -10,12 +10,14 @@ import { ParsedServerConfig } from '~/mcp/types'; export class ServerConfigsCacheInMemory { private readonly cache: Map = new Map(); - public async add(serverName: string, config: ParsedServerConfig): Promise { + public async add(serverName: string, config: ParsedServerConfig): Promise { if (this.cache.has(serverName)) throw new Error( `Server "${serverName}" already exists in cache. Use update() to modify existing configs.`, ); - this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() }); + const storedConfig = { ...config, updatedAt: Date.now() }; + this.cache.set(serverName, storedConfig); + return { serverName, config: storedConfig }; } public async update(serverName: string, config: ParsedServerConfig): Promise { @@ -23,7 +25,7 @@ export class ServerConfigsCacheInMemory { throw new Error( `Server "${serverName}" does not exist in cache. Use add() to create new configs.`, ); - this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() }); + this.cache.set(serverName, { ...config, updatedAt: Date.now() }); } public async remove(serverName: string): Promise { diff --git a/packages/api/src/mcp/registry/cache/ServerConfigsCacheRedis.ts b/packages/api/src/mcp/registry/cache/ServerConfigsCacheRedis.ts index f5126a640f..4532afa251 100644 --- a/packages/api/src/mcp/registry/cache/ServerConfigsCacheRedis.ts +++ b/packages/api/src/mcp/registry/cache/ServerConfigsCacheRedis.ts @@ -1,7 +1,7 @@ import type Keyv from 'keyv'; import { fromPairs } from 'lodash'; import { standardCache, keyvRedisClient } from '~/cache'; -import { ParsedServerConfig } from '~/mcp/types'; +import { ParsedServerConfig, AddServerResult } from '~/mcp/types'; import { BaseRegistryCache } from './BaseRegistryCache'; import { IServerConfigsRepositoryInterface } from '../ServerConfigsRepositoryInterface'; @@ -25,15 +25,17 @@ export class ServerConfigsCacheRedis this.cache = standardCache(`${this.PREFIX}::Servers::${namespace}`); } - public async add(serverName: string, config: ParsedServerConfig): Promise { + public async add(serverName: string, config: ParsedServerConfig): Promise { if (this.leaderOnly) await this.leaderCheck(`add ${this.namespace} MCP servers`); const exists = await this.cache.has(serverName); if (exists) throw new Error( `Server "${serverName}" already exists in cache. Use update() to modify existing configs.`, ); - const success = await this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() }); + const storedConfig = { ...config, updatedAt: Date.now() }; + const success = await this.cache.set(serverName, storedConfig); this.successCheck(`add ${this.namespace} server "${serverName}"`, success); + return { serverName, config: storedConfig }; } public async update(serverName: string, config: ParsedServerConfig): Promise { @@ -43,7 +45,7 @@ export class ServerConfigsCacheRedis throw new Error( `Server "${serverName}" does not exist in cache. Use add() to create new configs.`, ); - const success = await this.cache.set(serverName, { ...config, lastUpdatedAt: Date.now() }); + const success = await this.cache.set(serverName, { ...config, updatedAt: Date.now() }); this.successCheck(`update ${this.namespace} server "${serverName}"`, success); } diff --git a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts index 96536d9615..97d30caca0 100644 --- a/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts +++ b/packages/api/src/mcp/registry/cache/__tests__/ServerConfigsCacheInMemory.test.ts @@ -15,14 +15,14 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => { command: 'node', args: ['server1.js'], env: { TEST: 'value1' }, - lastUpdatedAt: FIXED_TIME, + updatedAt: FIXED_TIME, }; const mockConfig2: ParsedServerConfig = { command: 'python', args: ['server2.py'], env: { TEST: 'value2' }, - lastUpdatedAt: FIXED_TIME, + updatedAt: FIXED_TIME, }; const mockConfig3: ParsedServerConfig = { @@ -30,7 +30,7 @@ describe('ServerConfigsCacheInMemory Integration Tests', () => { args: ['server3.js'], url: 'http://localhost:3000', requiresOAuth: true, - lastUpdatedAt: FIXED_TIME, + updatedAt: FIXED_TIME, }; beforeAll(async () => { diff --git a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts index 81540e6e87..e6ef642d16 100644 --- a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts +++ b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts @@ -1,7 +1,14 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { AllMethods, createMethods, logger } from '@librechat/data-schemas'; +import { Types } from 'mongoose'; +import { + AccessRoleIds, + PermissionBits, + PrincipalType, + ResourceType, +} from 'librechat-data-provider'; +import { AllMethods, MCPServerDocument, createMethods, logger } from '@librechat/data-schemas'; import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface'; -import type { ParsedServerConfig } from '~/mcp/types'; +import { AccessControlService } from '~/acl/accessControlService'; +import type { ParsedServerConfig, AddServerResult } from '~/mcp/types'; /** * DB backed config storage @@ -10,35 +17,211 @@ import type { ParsedServerConfig } from '~/mcp/types'; */ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { private _dbMethods: AllMethods; + private _aclService: AccessControlService; + private _mongoose: typeof import('mongoose'); + constructor(mongoose: typeof import('mongoose')) { if (!mongoose) { throw new Error('ServerConfigsDB requires mongoose instance'); } + this._mongoose = mongoose; this._dbMethods = createMethods(mongoose); + this._aclService = new AccessControlService(mongoose); } - public async add(serverName: string, config: ParsedServerConfig, userId?: string): Promise { - logger.debug('ServerConfigsDB add not yet implemented'); - return; + /** + * Checks if user has access to an MCP server via an agent they can VIEW. + * @param serverName - The MCP server name to check + * @param userId - The user ID (optional - if not provided, checks publicly accessible agents) + * @returns true if user has VIEW access to at least one agent that has this MCP server + */ + private async hasAccessViaAgent(serverName: string, userId?: string): Promise { + let accessibleAgentIds: Types.ObjectId[]; + + if (!userId) { + // Get publicly accessible agents + accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({ + resourceType: ResourceType.AGENT, + requiredPermissions: PermissionBits.VIEW, + }); + } else { + // Get user-accessible agents + accessibleAgentIds = await this._aclService.findAccessibleResources({ + userId, + requiredPermissions: PermissionBits.VIEW, + resourceType: ResourceType.AGENT, + }); + } + + if (accessibleAgentIds.length === 0) { + return false; + } + + // Check if any accessible agent has this MCP server + const Agent = this._mongoose.model('Agent'); + const exists = await Agent.exists({ + _id: { $in: accessibleAgentIds }, + mcpServerNames: serverName, + }); + + return exists !== null; } + /** + * Creates a new MCP server and grants owner permissions to the user. + * @param serverName - Temporary server name (not persisted) will be replaced by the nano id generated by the db method + * @param config - Server configuration to store + * @param userId - ID of the user creating the server (required) + * @returns The created server result with serverName and config (including dbId) + * @throws Error if userId is not provided + */ + public async add( + serverName: string, + config: ParsedServerConfig, + userId?: string, + ): Promise { + logger.debug( + `[ServerConfigsDB.add] Starting Creating server with temp servername: ${serverName} for the user with the ID ${userId}`, + ); + if (!userId) { + throw new Error( + '[ServerConfigsDB.add] User ID is required to create a database-stored MCP server.', + ); + } + const createdServer = await this._dbMethods.createMCPServer({ config: config, author: userId }); + await this._aclService.grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.MCPSERVER, + resourceId: createdServer._id, + accessRoleId: AccessRoleIds.MCPSERVER_OWNER, + grantedBy: userId, + }); + return { + serverName: createdServer.serverName, + config: this.mapDBServerToParsedConfig(createdServer), + }; + } + + /** + * + * @param serverName mcp server unique identifier "serverName" + * @param config new Configuration to update + * @param userId user id required to update DB server config + */ public async update( serverName: string, config: ParsedServerConfig, userId?: string, ): Promise { - logger.debug('ServerConfigsDB update not yet implemented'); - return; + if (!userId) { + throw new Error( + '[ServerConfigsDB.update] User ID is required to update a database-stored MCP server.', + ); + } + + // Preserve sensitive fields (like oauth.client_secret) that may not be sent from the client + // Create a copy to avoid mutating the input parameter + let mergedConfig = config; + const existingServer = await this._dbMethods.findMCPServerById(serverName); + if (existingServer?.config?.oauth?.client_secret && !config.oauth?.client_secret) { + mergedConfig = { + ...config, + oauth: { + ...config.oauth, + client_secret: existingServer.config.oauth.client_secret, + }, + }; + } + + // specific user permissions for action permission will be handled in the controller calling the update method of the registry + await this._dbMethods.updateMCPServer(serverName, { config: mergedConfig }); } + /** + * Deletes an MCP server and removes all associated ACL entries. + * @param serverName - The serverName of the server to remove + * @param userId - User performing the deletion (for logging) + */ public async remove(serverName: string, userId?: string): Promise { - logger.debug('ServerConfigsDB remove not yet implemented'); - return; + logger.debug(`[ServerConfigsDB.remove] removing ${serverName}. UserId: ${userId}`); + const deletedServer = await this._dbMethods.deleteMCPServer(serverName); + if (deletedServer && deletedServer._id) { + logger.debug(`[ServerConfigsDB.remove] removing all permissions entries of ${serverName}.`); + await this._aclService.removeAllPermissions({ + resourceType: ResourceType.MCPSERVER, + resourceId: deletedServer._id!, + }); + return; + } + logger.warn(`[ServerConfigsDB.remove] server with serverName ${serverName} does not exist`); } + /** + * Retrieves a single MCP server configuration by its serverName. + * @param serverName - The serverName of the server to retrieve + * @param userId - the user id provide the scope of the request. If the user Id is not provided, only publicly visible servers are returned. + * @returns The parsed server config or undefined if not found. If accessed via agent, consumeOnly will be true. + */ public async get(serverName: string, userId?: string): Promise { - logger.debug('ServerConfigsDB get not yet implemented'); - return; + const server = await this._dbMethods.findMCPServerById(serverName); + if (!server) return undefined; + + // Check public access if no userId + if (!userId) { + const directlyAccessibleMCPIds = ( + await this._aclService.findPubliclyAccessibleResources({ + resourceType: ResourceType.MCPSERVER, + requiredPermissions: PermissionBits.VIEW, + }) + ).map((id) => id.toString()); + if (directlyAccessibleMCPIds.indexOf(server._id.toString()) > -1) { + return this.mapDBServerToParsedConfig(server); + } + + // Check access via publicly accessible agents + const hasAgentAccess = await this.hasAccessViaAgent(serverName); + if (hasAgentAccess) { + logger.debug( + `[ServerConfigsDB.get] accessing ${serverName} via public agent (consumeOnly)`, + ); + return { + ...this.mapDBServerToParsedConfig(server), + consumeOnly: true, + }; + } + + return undefined; + } + + // Check direct user access + const userHasDirectAccess = await this._aclService.checkPermission({ + userId, + resourceType: ResourceType.MCPSERVER, + requiredPermission: PermissionBits.VIEW, + resourceId: server._id, + }); + + if (userHasDirectAccess) { + logger.debug( + `[ServerConfigsDB.get] getting ${serverName} for user with the UserId: ${userId}`, + ); + return this.mapDBServerToParsedConfig(server); + } + + // Check agent access (user can VIEW an agent that has this MCP server) + const hasAgentAccess = await this.hasAccessViaAgent(serverName, userId); + if (hasAgentAccess) { + logger.debug( + `[ServerConfigsDB.get] user ${userId} accessing ${serverName} via agent (consumeOnly)`, + ); + return { + ...this.mapDBServerToParsedConfig(server), + consumeOnly: true, + }; + } + + return undefined; } /** @@ -47,13 +230,109 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { * @returns record of parsed configs */ public async getAll(userId?: string): Promise> { - // TODO: Implement DB-backed config retrieval - logger.debug('[ServerConfigsDB] getAll not yet implemented', { userId }); - return {}; + // 1. Get directly accessible MCP IDs + let directlyAccessibleMCPIds: Types.ObjectId[] = []; + if (!userId) { + logger.debug(`[ServerConfigsDB.getAll] fetching all publicly shared mcp servers`); + directlyAccessibleMCPIds = await this._aclService.findPubliclyAccessibleResources({ + resourceType: ResourceType.MCPSERVER, + requiredPermissions: PermissionBits.VIEW, + }); + } else { + logger.debug( + `[ServerConfigsDB.getAll] fetching mcp servers directly shared with the user with ID: ${userId}`, + ); + directlyAccessibleMCPIds = await this._aclService.findAccessibleResources({ + userId, + requiredPermissions: PermissionBits.VIEW, + resourceType: ResourceType.MCPSERVER, + }); + } + + // 2. Get agent-accessible MCP server names + let agentMCPServerNames: string[] = []; + let accessibleAgentIds: Types.ObjectId[] = []; + + if (!userId) { + // Get publicly accessible agents + accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({ + resourceType: ResourceType.AGENT, + requiredPermissions: PermissionBits.VIEW, + }); + } else { + // Get user-accessible agents + accessibleAgentIds = await this._aclService.findAccessibleResources({ + userId, + requiredPermissions: PermissionBits.VIEW, + resourceType: ResourceType.AGENT, + }); + } + + if (accessibleAgentIds.length > 0) { + // Efficient query: get agents with non-empty mcpServerNames + const Agent = this._mongoose.model('Agent'); + const agentsWithMCP = await Agent.find( + { + _id: { $in: accessibleAgentIds }, + mcpServerNames: { $exists: true, $not: { $size: 0 } }, + }, + { mcpServerNames: 1 }, + ).lean(); + + // Flatten and dedupe server names + agentMCPServerNames = [ + ...new Set( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agentsWithMCP.flatMap((a: any) => a.mcpServerNames || []), + ), + ]; + } + + // 3. Fetch directly accessible MCP servers + const directResults = await this._dbMethods.getListMCPServersByIds({ + ids: directlyAccessibleMCPIds, + }); + + // 4. Build result with direct access servers + const parsedConfigs: Record = {}; + const directServerNames = new Set(); + + for (const s of directResults.data || []) { + parsedConfigs[s.serverName] = this.mapDBServerToParsedConfig(s); + directServerNames.add(s.serverName); + } + + // 5. Fetch agent-accessible servers (excluding already direct) + const agentOnlyServerNames = agentMCPServerNames.filter((name) => !directServerNames.has(name)); + + if (agentOnlyServerNames.length > 0) { + const agentServers = await this._dbMethods.getListMCPServersByNames({ + names: agentOnlyServerNames, + }); + + for (const s of agentServers.data || []) { + parsedConfigs[s.serverName] = { + ...this.mapDBServerToParsedConfig(s), + consumeOnly: true, + }; + } + } + + return parsedConfigs; } + /** No-op for DB storage; logs a warning if called. */ public async reset(): Promise { logger.warn('Attempt to reset the DB config storage'); return; } + + /** Maps a MongoDB server document to the ParsedServerConfig format. */ + private mapDBServerToParsedConfig(serverDBDoc: MCPServerDocument): ParsedServerConfig { + return { + ...serverDBDoc.config, + dbId: (serverDBDoc._id as Types.ObjectId).toString(), + updatedAt: serverDBDoc.updatedAt?.getTime(), + }; + } } diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 0b6b2dbec7..346d6338e5 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -153,8 +153,15 @@ export type ParsedServerConfig = MCPOptions & { tools?: string; toolFunctions?: LCAvailableTools; initDuration?: number; - lastUpdatedAt?: number; + updatedAt?: number; dbId?: string; + /** True if access is only via agent (not directly shared with user) */ + consumeOnly?: boolean; +}; + +export type AddServerResult = { + serverName: string; + config: ParsedServerConfig; }; export interface BasicConnectionOptions { diff --git a/packages/api/src/mcp/utils.ts b/packages/api/src/mcp/utils.ts index 447754031e..fbd8ed7c95 100644 --- a/packages/api/src/mcp/utils.ts +++ b/packages/api/src/mcp/utils.ts @@ -45,3 +45,30 @@ export function sanitizeUrlForLogging(url: string | URL): string { return '[invalid URL]'; } } + +/** + * Escapes special regex characters in a string so they are treated literally. + * @param str - The string to escape + * @returns The escaped string safe for use in a regex pattern + */ +export function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Generates a URL-friendly server name from a title. + * Converts to lowercase, replaces spaces with hyphens, removes special characters. + * @param title - The display title to convert + * @returns A slug suitable for use as serverName (e.g., "GitHub MCP Tool" → "github-mcp-tool") + */ +export function generateServerNameFromTitle(title: string): string { + const slug = title + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') // Remove special chars except spaces and hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Remove consecutive hyphens + .replace(/^-|-$/g, ''); // Trim leading/trailing hyphens + + return slug || 'mcp-server'; // Fallback if empty +} diff --git a/packages/client/src/components/MultiSelect.tsx b/packages/client/src/components/MultiSelect.tsx index 8fa60137ea..66efe92b1a 100644 --- a/packages/client/src/components/MultiSelect.tsx +++ b/packages/client/src/components/MultiSelect.tsx @@ -11,12 +11,26 @@ import { import './AnimatePopover.css'; import { cn } from '~/utils'; +type MultiSelectItem = T | { label: string; value: T }; + +function getItemValue(item: MultiSelectItem): T { + return typeof item === 'string' ? item : item.value; +} + +function getItemLabel(item: MultiSelectItem): string { + return typeof item === 'string' ? item : item.label; +} + interface MultiSelectProps { - items: T[]; + items: MultiSelectItem[]; label?: string; placeholder?: string; onSelectedValuesChange?: (values: T[]) => void; - renderSelectedValues?: (values: T[], placeholder?: string) => React.ReactNode; + renderSelectedValues?: ( + values: T[], + placeholder?: string, + items?: MultiSelectItem[], + ) => React.ReactNode; className?: string; itemClassName?: string; labelClassName?: string; @@ -33,11 +47,22 @@ interface MultiSelectProps { ) => React.ReactNode; } -function defaultRender(values: T[], placeholder?: string) { +function defaultRender( + values: T[], + placeholder?: string, + items?: MultiSelectItem[], +) { if (values.length === 0) { return placeholder || 'Select...'; } if (values.length === 1) { + // Find the item to get its label + if (items) { + const item = items.find((item) => getItemValue(item) === values[0]); + if (item) { + return getItemLabel(item); + } + } return values[0]; } return `${values.length} items selected`; @@ -90,7 +115,7 @@ export default function MultiSelect({ > {selectIcon && {selectIcon as React.JSX.Element}} - {renderSelectedValues(selectedValues, placeholder)} + {renderSelectedValues(selectedValues, placeholder, items)} @@ -109,11 +134,13 @@ export default function MultiSelect({ popoverClassName, )} > - {items.map((value) => { + {items.map((item) => { + const value = getItemValue(item); + const label = getItemLabel(item); const defaultContent = ( <> - {value} + {label} ); const isCurrentItemSelected = selectedValues.includes(value); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 447cb1e66a..c487529285 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -8,6 +8,7 @@ "declaration": true, "declarationMap": true, "declarationDir": "./dist/types", + "sourceMap": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, diff --git a/packages/data-provider/src/accessPermissions.ts b/packages/data-provider/src/accessPermissions.ts index 3c5a232c65..5fa288e4b3 100644 --- a/packages/data-provider/src/accessPermissions.ts +++ b/packages/data-provider/src/accessPermissions.ts @@ -45,6 +45,7 @@ export type TAccessLevel = 'none' | 'viewer' | 'editor' | 'owner'; export enum ResourceType { AGENT = 'agent', PROMPTGROUP = 'promptGroup', + MCPSERVER = 'mcpServer', } /** @@ -71,6 +72,9 @@ export enum AccessRoleIds { PROMPTGROUP_VIEWER = 'promptGroup_viewer', PROMPTGROUP_EDITOR = 'promptGroup_editor', PROMPTGROUP_OWNER = 'promptGroup_owner', + MCPSERVER_VIEWER = 'mcpServer_viewer', + MCPSERVER_EDITOR = 'mcpServer_editor', + MCPSERVER_OWNER = 'mcpServer_owner', } // ===== ZOD SCHEMAS ===== @@ -269,6 +273,12 @@ export const effectivePermissionsResponseSchema = z.object({ */ export type TEffectivePermissionsResponse = z.infer; +/** + * All effective permissions response type + * Map of resourceId to permissionBits for all accessible resources + */ +export type TAllEffectivePermissionsResponse = Record; + // ===== UTILITY TYPES ===== /** diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 417375386d..aa97a75303 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -231,6 +231,8 @@ export const mcp = { servers: `${BASE_URL}/api/mcp/servers`, }; +export const mcpServer = (serverName: string) => `${BASE_URL}/api/mcp/servers/${serverName}`; + export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`; export const files = () => `${BASE_URL}/api/files`; @@ -319,6 +321,7 @@ export const updateMemoryPermissions = (roleName: string) => `${getRole(roleName export const updateAgentPermissions = (roleName: string) => `${getRole(roleName)}/agents`; export const updatePeoplePickerPermissions = (roleName: string) => `${getRole(roleName)}/people-picker`; +export const updateMCPServersPermissions = (roleName: string) => `${getRole(roleName)}/mcp-servers`; export const updateMarketplacePermissions = (roleName: string) => `${getRole(roleName)}/marketplace`; @@ -383,6 +386,9 @@ export const updateResourcePermissions = (resourceType: ResourceType, resourceId export const getEffectivePermissions = (resourceType: ResourceType, resourceId: string) => `${BASE_URL}/api/permissions/${resourceType}/${resourceId}/effective`; +export const getAllEffectivePermissions = (resourceType: ResourceType) => + `${BASE_URL}/api/permissions/${resourceType}/effective/all`; + // SharePoint Graph API Token export const graphToken = (scopes: string) => `${BASE_URL}/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 1604e8588b..580aa1ac66 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -515,9 +515,14 @@ const termsOfServiceSchema = z.object({ export type TTermsOfService = z.infer; -const mcpServersSchema = z.object({ - placeholder: z.string().optional(), -}); +const mcpServersSchema = z + .object({ + placeholder: z.string().optional(), + use: z.boolean().optional(), + create: z.boolean().optional(), + share: z.boolean().optional(), + }) + .optional(); export type TMcpServersConfig = z.infer; @@ -583,6 +588,11 @@ export const interfaceSchema = z marketplace: { use: false, }, + mcpServers: { + use: true, + create: true, + share: false, + }, fileSearch: true, fileCitations: true, }); @@ -675,6 +685,7 @@ export type TStartupConfig = { chatMenu?: boolean; isOAuth?: boolean; startup?: boolean; + iconPath?: string; } >; mcpPlaceholder?: string; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 88ad31db0c..21d5251388 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -565,6 +565,39 @@ export const getMCPServers = async (): Promise => { return request.get(endpoints.mcp.servers); }; +/** + * Get a single MCP server by ID + */ +export const getMCPServer = async (serverName: string): Promise => { + return request.get(endpoints.mcpServer(serverName)); +}; + +/** + * Create a new MCP server + */ +export const createMCPServer = async ( + data: mcp.MCPServerCreateParams, +): Promise => { + return request.post(endpoints.mcp.servers, data); +}; + +/** + * Update an existing MCP server + */ +export const updateMCPServer = async ( + serverName: string, + data: mcp.MCPServerUpdateParams, +): Promise => { + return request.patch(endpoints.mcpServer(serverName), data); +}; + +/** + * Delete an MCP server + */ +export const deleteMCPServer = async (serverName: string): Promise<{ success: boolean }> => { + return request.delete(endpoints.mcpServer(serverName)); +}; + /** * Imports a conversations file. * @@ -832,6 +865,12 @@ export function updatePeoplePickerPermissions( ); } +export function updateMCPServersPermissions( + variables: m.UpdateMCPServersPermVars, +): Promise { + return request.put(endpoints.updateMCPServersPermissions(variables.roleName), variables.updates); +} + export function updateMarketplacePermissions( variables: m.UpdateMarketplacePermVars, ): Promise { @@ -984,6 +1023,12 @@ export function getEffectivePermissions( return request.get(endpoints.getEffectivePermissions(resourceType, resourceId)); } +export function getAllEffectivePermissions( + resourceType: permissions.ResourceType, +): Promise { + return request.get(endpoints.getAllEffectivePermissions(resourceType)); +} + // SharePoint Graph API Token export function getGraphApiToken(params: q.GraphTokenParams): Promise { return request.get(endpoints.graphToken(params.scopes)); diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index fa4656722d..879435d411 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -59,6 +59,7 @@ export enum QueryKeys { graphToken = 'graphToken', /* MCP Servers */ mcpServers = 'mcpServers', + mcpServer = 'mcpServer', } // Dynamic query keys that require parameters diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index dd359d750a..66d3abd3df 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -3,6 +3,13 @@ import { TokenExchangeMethodEnum } from './types/agents'; import { extractEnvVariable } from './utils'; const BaseOptionsSchema = z.object({ + /** Display name for the MCP server - only letters, numbers, and spaces allowed */ + title: z + .string() + .regex(/^[a-zA-Z0-9 ]+$/, 'Title can only contain letters, numbers, and spaces') + .optional(), + /** Description of the MCP server */ + description: z.string().optional(), /** * Controls whether the MCP server is initialized during application startup. * - true (default): Server is initialized during app startup and included in app-level connections diff --git a/packages/data-provider/src/permissions.ts b/packages/data-provider/src/permissions.ts index 1621ea73e0..b5c90aadeb 100644 --- a/packages/data-provider/src/permissions.ts +++ b/packages/data-provider/src/permissions.ts @@ -52,6 +52,10 @@ export enum PermissionTypes { * Type for using the "File Citations" feature in agents */ FILE_CITATIONS = 'FILE_CITATIONS', + /** + * Type for MCP Server Permissions + */ + MCP_SERVERS = 'MCP_SERVERS', } /** @@ -144,6 +148,13 @@ export const fileCitationsPermissionsSchema = z.object({ }); export type TFileCitationsPermissions = z.infer; +export const mcpServersPermissionsSchema = z.object({ + [Permissions.USE]: z.boolean().default(true), + [Permissions.CREATE]: z.boolean().default(true), + [Permissions.SHARE]: z.boolean().default(false), +}); +export type TMcpServersPermissions = z.infer; + // Define a single permissions schema that holds all permission types. export const permissionsSchema = z.object({ [PermissionTypes.PROMPTS]: promptPermissionsSchema, @@ -158,4 +169,5 @@ export const permissionsSchema = z.object({ [PermissionTypes.MARKETPLACE]: marketplacePermissionsSchema, [PermissionTypes.FILE_SEARCH]: fileSearchPermissionsSchema, [PermissionTypes.FILE_CITATIONS]: fileCitationsPermissionsSchema, + [PermissionTypes.MCP_SERVERS]: mcpServersPermissionsSchema, }); diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index c4b55e2039..cd259fb50c 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -493,6 +493,20 @@ export const useGetEffectivePermissionsQuery = ( }); }; +export const useGetAllEffectivePermissionsQuery = ( + resourceType: ResourceType, + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery({ + queryKey: [QueryKeys.effectivePermissions, 'all', resourceType], + queryFn: () => dataService.getAllEffectivePermissions(resourceType), + enabled: !!resourceType, + refetchOnWindowFocus: false, + staleTime: 30000, + ...config, + }); +}; + export const useMCPServerConnectionStatusQuery = ( serverName: string, config?: UseQueryOptions, diff --git a/packages/data-provider/src/roles.ts b/packages/data-provider/src/roles.ts index 22d20ebd5a..02fbeaf6db 100644 --- a/packages/data-provider/src/roles.ts +++ b/packages/data-provider/src/roles.ts @@ -14,6 +14,7 @@ import { temporaryChatPermissionsSchema, peoplePickerPermissionsSchema, fileCitationsPermissionsSchema, + mcpServersPermissionsSchema, } from './permissions'; /** @@ -89,6 +90,11 @@ const defaultRolesSchema = z.object({ [PermissionTypes.FILE_CITATIONS]: fileCitationsPermissionsSchema.extend({ [Permissions.USE]: z.boolean().default(true), }), + [PermissionTypes.MCP_SERVERS]: mcpServersPermissionsSchema.extend({ + [Permissions.USE]: z.boolean().default(true), + [Permissions.CREATE]: z.boolean().default(true), + [Permissions.SHARE]: z.boolean().default(true), + }), }), }), [SystemRoles.USER]: roleSchema.extend({ @@ -147,6 +153,11 @@ export const roleDefaults = defaultRolesSchema.parse({ [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true, }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + }, }, }, [SystemRoles.USER]: { @@ -170,6 +181,7 @@ export const roleDefaults = defaultRolesSchema.parse({ }, [PermissionTypes.FILE_SEARCH]: {}, [PermissionTypes.FILE_CITATIONS]: {}, + [PermissionTypes.MCP_SERVERS]: {}, }, }, }); diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 756ae51e7f..f9101e782e 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -333,7 +333,7 @@ export type ActionMetadataRuntime = ActionMetadata & { }; export type MCP = { - mcp_id: string; + serverName: string; metadata: MCPMetadata; } & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string }); diff --git a/packages/data-provider/src/types/index.ts b/packages/data-provider/src/types/index.ts index 3cf1ef310b..bd6e3ad79b 100644 --- a/packages/data-provider/src/types/index.ts +++ b/packages/data-provider/src/types/index.ts @@ -1 +1,2 @@ export * from './queries'; +export * from './mcpServers'; diff --git a/packages/data-provider/src/types/mcpServers.ts b/packages/data-provider/src/types/mcpServers.ts index ad27445b75..27520a11d5 100644 --- a/packages/data-provider/src/types/mcpServers.ts +++ b/packages/data-provider/src/types/mcpServers.ts @@ -1,4 +1,3 @@ -import { PermissionBits } from '../accessPermissions'; import type { MCPOptions, MCPServerUserInput } from '../mcp'; /** @@ -7,7 +6,7 @@ import type { MCPOptions, MCPServerUserInput } from '../mcp'; */ export interface IMCPServerDB { _id?: string; // MongoDB ObjectId (used for ACL/permissions) - mcp_id: string; + serverName: string; config: MCPOptions; author?: string | null; createdAt?: Date; @@ -41,12 +40,10 @@ export type MCPServerUpdateParams = { * Response for MCP server list endpoint */ export type MCPServerDBObjectResponse = { - _id?: string; - mcp_id?: string; - author?: string | null; - createdAt?: Date; - updatedAt?: Date; - effectivePermissions?: PermissionBits; + dbId?: string; + serverName: string; + /** True if access is only via agent (not directly shared with user) */ + consumeOnly?: boolean; } & MCPOptions; export type MCPServersListResponse = Record; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 810fa15e49..4f8c1857b1 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -274,6 +274,7 @@ export type UpdatePromptPermVars = UpdatePermVars; export type UpdateMemoryPermVars = UpdatePermVars; export type UpdateAgentPermVars = UpdatePermVars; export type UpdatePeoplePickerPermVars = UpdatePermVars; +export type UpdateMCPServersPermVars = UpdatePermVars; export type UpdatePermResponse = r.TRole; @@ -305,6 +306,13 @@ export type UpdatePeoplePickerPermOptions = MutationOptions< types.TError | null | undefined >; +export type UpdateMCPServersPermOptions = MutationOptions< + UpdatePermResponse, + UpdateMCPServersPermVars, + unknown, + types.TError | null | undefined +>; + export type UpdateMarketplacePermVars = UpdatePermVars; export type UpdateMarketplacePermOptions = MutationOptions< diff --git a/packages/data-schemas/src/methods/accessRole.spec.ts b/packages/data-schemas/src/methods/accessRole.spec.ts index 15a22b1eb1..b0f7ba70ef 100644 --- a/packages/data-schemas/src/methods/accessRole.spec.ts +++ b/packages/data-schemas/src/methods/accessRole.spec.ts @@ -200,6 +200,9 @@ describe('AccessRole Model Tests', () => { AccessRoleIds.PROMPTGROUP_EDITOR, AccessRoleIds.PROMPTGROUP_OWNER, AccessRoleIds.PROMPTGROUP_VIEWER, + AccessRoleIds.MCPSERVER_EDITOR, + AccessRoleIds.MCPSERVER_OWNER, + AccessRoleIds.MCPSERVER_VIEWER, ].sort(), ); diff --git a/packages/data-schemas/src/methods/accessRole.ts b/packages/data-schemas/src/methods/accessRole.ts index d15583e38c..0971c0f9e8 100644 --- a/packages/data-schemas/src/methods/accessRole.ts +++ b/packages/data-schemas/src/methods/accessRole.ts @@ -146,6 +146,27 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')) { resourceType: ResourceType.PROMPTGROUP, permBits: RoleBits.OWNER, }, + { + accessRoleId: AccessRoleIds.MCPSERVER_VIEWER, + name: 'com_ui_mcp_server_role_viewer', + description: 'com_ui_mcp_server_role_viewer_desc', + resourceType: ResourceType.MCPSERVER, + permBits: RoleBits.VIEWER, + }, + { + accessRoleId: AccessRoleIds.MCPSERVER_EDITOR, + name: 'com_ui_mcp_server_role_editor', + description: 'com_ui_mcp_server_role_editor_desc', + resourceType: ResourceType.MCPSERVER, + permBits: RoleBits.EDITOR, + }, + { + accessRoleId: AccessRoleIds.MCPSERVER_OWNER, + name: 'com_ui_mcp_server_role_owner', + description: 'com_ui_mcp_server_role_owner_desc', + resourceType: ResourceType.MCPSERVER, + permBits: RoleBits.OWNER, + }, ]; const result: Record = {}; diff --git a/packages/data-schemas/src/methods/aclEntry.spec.ts b/packages/data-schemas/src/methods/aclEntry.spec.ts index be18a81c1b..804b51870a 100644 --- a/packages/data-schemas/src/methods/aclEntry.spec.ts +++ b/packages/data-schemas/src/methods/aclEntry.spec.ts @@ -679,4 +679,276 @@ describe('AclEntry Model Tests', () => { expect(effective).toBe(PermissionBits.VIEW); }); }); + + describe('Batch Permission Queries', () => { + test('should get effective 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 methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource1, + PermissionBits.VIEW, + grantedById, + ); + + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource2, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.MCPSERVER, + resource3, + PermissionBits.DELETE, + grantedById, + ); + + /** Get permissions for all resources */ + const permissionsMap = await methods.getEffectivePermissionsForResources( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.MCPSERVER, + [resource1, resource2, resource3], + ); + + expect(permissionsMap.size).toBe(2); // Only resource1 and resource2 for user + expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW); + expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT); + expect(permissionsMap.get(resource3.toString())).toBeUndefined(); // User has no access + }); + + test('should combine permissions from multiple principals in batch query', async () => { + const resource1 = new mongoose.Types.ObjectId(); + const resource2 = new mongoose.Types.ObjectId(); + + /** User has VIEW on both resources */ + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource1, + PermissionBits.VIEW, + grantedById, + ); + + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource2, + PermissionBits.VIEW, + grantedById, + ); + + /** Group has EDIT on resource1 */ + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.MCPSERVER, + resource1, + PermissionBits.EDIT, + grantedById, + ); + + /** Get combined permissions for user + group */ + const permissionsMap = await methods.getEffectivePermissionsForResources( + [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ], + ResourceType.MCPSERVER, + [resource1, resource2], + ); + + expect(permissionsMap.size).toBe(2); + /** Resource1 should have VIEW | EDIT (from user + group) */ + expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT); + /** Resource2 should have only VIEW (from user) */ + expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.VIEW); + }); + + test('should handle empty resource list', async () => { + const permissionsMap = await methods.getEffectivePermissionsForResources( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.MCPSERVER, + [], + ); + + expect(permissionsMap.size).toBe(0); + }); + + test('should handle resources with no permissions', async () => { + const resource1 = new mongoose.Types.ObjectId(); + const resource2 = new mongoose.Types.ObjectId(); + + /** Only grant permission to resource1 */ + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource1, + PermissionBits.VIEW, + grantedById, + ); + + const permissionsMap = await methods.getEffectivePermissionsForResources( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.MCPSERVER, + [resource1, resource2], // resource2 has no permissions + ); + + expect(permissionsMap.size).toBe(1); + expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW); + expect(permissionsMap.get(resource2.toString())).toBeUndefined(); + }); + + test('should include public permissions in batch query', async () => { + const resource1 = new mongoose.Types.ObjectId(); + const resource2 = new mongoose.Types.ObjectId(); + + /** User has VIEW on resource1 */ + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource1, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + /** Public has VIEW on resource2 */ + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.MCPSERVER, + resource2, + PermissionBits.VIEW, + grantedById, + ); + + /** Query with user + public principals */ + const permissionsMap = await methods.getEffectivePermissionsForResources( + [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.PUBLIC }, + ], + ResourceType.MCPSERVER, + [resource1, resource2], + ); + + expect(permissionsMap.size).toBe(2); + expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT); + expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.VIEW); + }); + + test('should handle large batch efficiently', async () => { + /** Create 50 resources with various permissions */ + const resources = Array.from({ length: 50 }, () => new mongoose.Types.ObjectId()); + + /** Grant permissions to first 30 resources */ + for (let i = 0; i < 30; i++) { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resources[i], + PermissionBits.VIEW, + grantedById, + ); + } + + /** Grant group permissions to resources 20-40 (overlap with user) */ + for (let i = 20; i < 40; i++) { + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.MCPSERVER, + resources[i], + PermissionBits.EDIT, + grantedById, + ); + } + + const startTime = Date.now(); + const permissionsMap = await methods.getEffectivePermissionsForResources( + [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ], + ResourceType.MCPSERVER, + resources, + ); + const duration = Date.now() - startTime; + + /** Should be reasonably fast (under 1 second for 50 resources) */ + 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(PermissionBits.VIEW); + } + + /** Resources 20-29: USER VIEW | GROUP EDIT */ + for (let i = 20; i < 30; i++) { + expect(permissionsMap.get(resources[i].toString())).toBe(PermissionBits.VIEW | PermissionBits.EDIT); + } + + /** Resources 30-39: GROUP EDIT only */ + for (let i = 30; i < 40; i++) { + expect(permissionsMap.get(resources[i].toString())).toBe(PermissionBits.EDIT); + } + + /** Resources 40-49: No permissions */ + for (let i = 40; i < 50; i++) { + expect(permissionsMap.get(resources[i].toString())).toBeUndefined(); + } + }); + + test('should handle mixed ObjectId and string resource IDs', async () => { + const resource1 = new mongoose.Types.ObjectId(); + const resource2 = new mongoose.Types.ObjectId(); + + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource1, + PermissionBits.VIEW, + grantedById, + ); + + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + resource2, + PermissionBits.EDIT, + grantedById, + ); + + /** Pass mix of ObjectId and string */ + const permissionsMap = await methods.getEffectivePermissionsForResources( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.MCPSERVER, + [resource1, resource2.toString()], // Mix of ObjectId and string + ); + + expect(permissionsMap.size).toBe(2); + expect(permissionsMap.get(resource1.toString())).toBe(PermissionBits.VIEW); + expect(permissionsMap.get(resource2.toString())).toBe(PermissionBits.EDIT); + }); + }); }); diff --git a/packages/data-schemas/src/methods/aclEntry.ts b/packages/data-schemas/src/methods/aclEntry.ts index 67e91ad018..c1848960cc 100644 --- a/packages/data-schemas/src/methods/aclEntry.ts +++ b/packages/data-schemas/src/methods/aclEntry.ts @@ -118,6 +118,58 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { return effectiveBits; } + /** + * Get effective permissions for multiple resources in a single query (BATCH) + * Returns a map of resourceId → effectivePermissionBits + * + * @param principalsList - List of principals (user + groups + public) + * @param resourceType - The type of resource ('MCPSERVER', 'AGENT', etc.) + * @param resourceIds - Array of resource IDs to check + * @returns {Promise>} Map of resourceId → permission bits + * + * @example + * const principals = await getUserPrincipals({ userId, role }); + * const serverIds = [id1, id2, id3]; + * const permMap = await getEffectivePermissionsForResources( + * principals, + * ResourceType.MCPSERVER, + * serverIds + * ); + * // permMap.get(id1.toString()) → 7 (VIEW|EDIT|DELETE) + */ + async function getEffectivePermissionsForResources( + principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>, + resourceType: string, + resourceIds: Array, + ): Promise> { + if (!Array.isArray(resourceIds) || resourceIds.length === 0) { + return new Map(); + } + + const AclEntry = mongoose.models.AclEntry as Model; + const principalsQuery = principalsList.map((p) => ({ + principalType: p.principalType, + ...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }), + })); + + // Batch query for all resources at once + const aclEntries = await AclEntry.find({ + $or: principalsQuery, + resourceType, + resourceId: { $in: resourceIds }, + }).lean(); + + // Compute effective permissions per resource + const permissionsMap = new Map(); + for (const entry of aclEntries) { + const rid = entry.resourceId.toString(); + const currentBits = permissionsMap.get(rid) || 0; + permissionsMap.set(rid, currentBits | entry.permBits); + } + + return permissionsMap; + } + /** * Grant permission to a principal for a resource * @param principalType - The type of principal ('user', 'group', 'public') @@ -301,6 +353,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { findEntriesByPrincipalsAndResource, hasPermission, getEffectivePermissions, + getEffectivePermissionsForResources, grantPermission, revokePermission, modifyPermissionBits, diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index cb93f8d95a..122e48419c 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -8,6 +8,8 @@ import { createFileMethods, type FileMethods } from './file'; import { createMemoryMethods, type MemoryMethods } from './memory'; /* Agent Categories */ import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCategory'; +/* MCP Servers */ +import { createMCPServerMethods, type MCPServerMethods } from './mcpServer'; /* Plugin Auth */ import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth'; /* Permissions */ @@ -24,6 +26,7 @@ export type AllMethods = UserMethods & FileMethods & MemoryMethods & AgentCategoryMethods & + MCPServerMethods & UserGroupMethods & AclEntryMethods & ShareMethods & @@ -44,6 +47,7 @@ export function createMethods(mongoose: typeof import('mongoose')): AllMethods { ...createFileMethods(mongoose), ...createMemoryMethods(mongoose), ...createAgentCategoryMethods(mongoose), + ...createMCPServerMethods(mongoose), ...createAccessRoleMethods(mongoose), ...createUserGroupMethods(mongoose), ...createAclEntryMethods(mongoose), @@ -61,6 +65,7 @@ export type { FileMethods, MemoryMethods, AgentCategoryMethods, + MCPServerMethods, UserGroupMethods, AclEntryMethods, ShareMethods, diff --git a/packages/data-schemas/src/methods/mcpServer.spec.ts b/packages/data-schemas/src/methods/mcpServer.spec.ts new file mode 100644 index 0000000000..387a697ffc --- /dev/null +++ b/packages/data-schemas/src/methods/mcpServer.spec.ts @@ -0,0 +1,827 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { MCPOptions } from 'librechat-data-provider'; +import type * as t from '~/types'; +import { createMCPServerMethods } from './mcpServer'; +import mcpServerSchema from '~/schema/mcpServer'; + +let mongoServer: MongoMemoryServer; +let MCPServer: mongoose.Model; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema); + methods = createMCPServerMethods(mongoose); + await mongoose.connect(mongoUri); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +describe('MCPServer Model Tests', () => { + const authorId = new mongoose.Types.ObjectId(); + const authorId2 = new mongoose.Types.ObjectId(); + + const createSSEConfig = (title?: string, description?: string): MCPOptions => ({ + type: 'sse', + url: 'https://example.com/mcp', + ...(title && { title }), + ...(description && { description }), + }); + + describe('createMCPServer', () => { + test('should create server with title and generate slug from title', async () => { + const config = createSSEConfig('My Test Server', 'A test server'); + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server).toBeDefined(); + expect(server.serverName).toBe('my-test-server'); + expect(server.config.title).toBe('My Test Server'); + expect(server.config.description).toBe('A test server'); + expect(server.author.toString()).toBe(authorId.toString()); + expect(server.createdAt).toBeInstanceOf(Date); + expect(server.updatedAt).toBeInstanceOf(Date); + }); + + test('should create server without title and use nanoid', async () => { + const config: MCPOptions = { + type: 'sse', + url: 'https://example.com/mcp', + }; + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server).toBeDefined(); + expect(server.serverName).toMatch(/^mcp-[a-zA-Z0-9_-]{16}$/); + expect(server.config.title).toBeUndefined(); + }); + + test('should handle title with special characters', async () => { + const config = createSSEConfig('My @#$% Server!!! 123'); + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server.serverName).toBe('my-server-123'); + }); + + test('should handle title with only spaces and special chars', async () => { + const config = createSSEConfig(' @#$% '); + const server = await methods.createMCPServer({ config, author: authorId }); + + // Should fallback to 'mcp-server' + expect(server.serverName).toBe('mcp-server'); + }); + + test('should handle title with multiple spaces', async () => { + const config = createSSEConfig('My Multiple Spaces Server'); + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server.serverName).toBe('my-multiple-spaces-server'); + }); + + test('should handle string author ID', async () => { + const config = createSSEConfig('String Author Test'); + const server = await methods.createMCPServer({ + config, + author: authorId.toString(), + }); + + expect(server).toBeDefined(); + expect(server.author.toString()).toBe(authorId.toString()); + }); + + test('should create server with stdio config', async () => { + const config: MCPOptions = { + type: 'stdio', + command: 'node', + args: ['server.js'], + title: 'Stdio Server', + }; + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server.serverName).toBe('stdio-server'); + expect(server.config.type).toBe('stdio'); + }); + }); + + describe('findNextAvailableServerName', () => { + test('should return base name when no duplicates exist', async () => { + // Create server directly via model to set up initial state + await MCPServer.create({ + serverName: 'other-server', + config: createSSEConfig('Other Server'), + author: authorId, + }); + + const config = createSSEConfig('Test Server'); + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server.serverName).toBe('test-server'); + }); + + test('should append -2 when base name exists', async () => { + // Create first server + await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + }); + + // Create second server with same title + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + }); + + expect(server.serverName).toBe('test-server-2'); + }); + + test('should find next available number in sequence', async () => { + // Create servers with sequential names + await MCPServer.create({ + serverName: 'test-server', + config: createSSEConfig('Test Server'), + author: authorId, + }); + await MCPServer.create({ + serverName: 'test-server-2', + config: createSSEConfig('Test Server'), + author: authorId, + }); + await MCPServer.create({ + serverName: 'test-server-3', + config: createSSEConfig('Test Server'), + author: authorId, + }); + + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + }); + + expect(server.serverName).toBe('test-server-4'); + }); + + test('should handle gaps in sequence', async () => { + // Create servers with gaps: test, test-2, test-5 + await MCPServer.create({ + serverName: 'test-server', + config: createSSEConfig('Test Server'), + author: authorId, + }); + await MCPServer.create({ + serverName: 'test-server-2', + config: createSSEConfig('Test Server'), + author: authorId, + }); + await MCPServer.create({ + serverName: 'test-server-5', + config: createSSEConfig('Test Server'), + author: authorId, + }); + + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + }); + + // Should append -6 (max + 1) + expect(server.serverName).toBe('test-server-6'); + }); + + test('should not match partial names', async () => { + // Create 'test-server-extra' which shouldn't affect 'test-server' sequence + await MCPServer.create({ + serverName: 'test-server-extra', + config: createSSEConfig('Test Server Extra'), + author: authorId, + }); + + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + }); + + // 'test-server' is available, so should use it + expect(server.serverName).toBe('test-server'); + }); + + test('should handle special regex characters in base name', async () => { + // The slug generation removes special characters, but test the regex escaping + await MCPServer.create({ + serverName: 'test-server', + config: createSSEConfig('Test Server'), + author: authorId, + }); + + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId2, + }); + + expect(server.serverName).toBe('test-server-2'); + }); + }); + + describe('findMCPServerById', () => { + test('should find server by serverName', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Find By Id Test'), + author: authorId, + }); + + const found = await methods.findMCPServerById(created.serverName); + + expect(found).toBeDefined(); + expect(found?.serverName).toBe('find-by-id-test'); + expect(found?.config.title).toBe('Find By Id Test'); + }); + + test('should return null when server not found', async () => { + const found = await methods.findMCPServerById('non-existent-server'); + + expect(found).toBeNull(); + }); + + test('should return lean document', async () => { + await methods.createMCPServer({ + config: createSSEConfig('Lean Test'), + author: authorId, + }); + + const found = await methods.findMCPServerById('lean-test'); + + // Lean documents don't have mongoose methods + expect(found).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(typeof (found as any).save).toBe('undefined'); + }); + }); + + describe('findMCPServerByObjectId', () => { + test('should find server by MongoDB ObjectId', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Object Id Test'), + author: authorId, + }); + + const found = await methods.findMCPServerByObjectId(created._id); + + expect(found).toBeDefined(); + expect(found?.serverName).toBe('object-id-test'); + expect(found?._id.toString()).toBe(created._id.toString()); + }); + + test('should find server by string ObjectId', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('String Object Id Test'), + author: authorId, + }); + + const found = await methods.findMCPServerByObjectId(created._id.toString()); + + expect(found).toBeDefined(); + expect(found?.serverName).toBe('string-object-id-test'); + }); + + test('should return null when ObjectId not found', async () => { + const randomId = new mongoose.Types.ObjectId(); + const found = await methods.findMCPServerByObjectId(randomId); + + expect(found).toBeNull(); + }); + + test('should return null for invalid ObjectId string', async () => { + await expect(methods.findMCPServerByObjectId('invalid-id')).rejects.toThrow(); + }); + }); + + describe('findMCPServersByAuthor', () => { + test('should find all servers by author', async () => { + await methods.createMCPServer({ + config: createSSEConfig('Author Server 1'), + author: authorId, + }); + await methods.createMCPServer({ + config: createSSEConfig('Author Server 2'), + author: authorId, + }); + await methods.createMCPServer({ + config: createSSEConfig('Other Author Server'), + author: authorId2, + }); + + const servers = await methods.findMCPServersByAuthor(authorId); + + expect(servers).toHaveLength(2); + expect(servers.every((s) => s.author.toString() === authorId.toString())).toBe(true); + }); + + test('should return empty array when author has no servers', async () => { + const servers = await methods.findMCPServersByAuthor(new mongoose.Types.ObjectId()); + + expect(servers).toEqual([]); + }); + + test('should sort by updatedAt descending', async () => { + // Create servers with slight delay to ensure different timestamps + const server1 = await methods.createMCPServer({ + config: createSSEConfig('First Created'), + author: authorId, + }); + + // Update first server to make it most recently updated + await MCPServer.findByIdAndUpdate(server1._id, { + $set: { 'config.description': 'Updated' }, + }); + + await methods.createMCPServer({ + config: createSSEConfig('Second Created'), + author: authorId, + }); + + const servers = await methods.findMCPServersByAuthor(authorId); + + expect(servers).toHaveLength(2); + // Most recently updated should come first + expect(servers[0].serverName).toBe('second-created'); + }); + + test('should handle string author ID', async () => { + await methods.createMCPServer({ + config: createSSEConfig('String Author Server'), + author: authorId, + }); + + const servers = await methods.findMCPServersByAuthor(authorId.toString()); + + expect(servers).toHaveLength(1); + }); + }); + + describe('getListMCPServersByIds', () => { + let server1: t.MCPServerDocument; + let server2: t.MCPServerDocument; + let server3: t.MCPServerDocument; + + beforeEach(async () => { + server1 = await methods.createMCPServer({ + config: createSSEConfig('Server One'), + author: authorId, + }); + server2 = await methods.createMCPServer({ + config: createSSEConfig('Server Two'), + author: authorId, + }); + server3 = await methods.createMCPServer({ + config: createSSEConfig('Server Three'), + author: authorId, + }); + }); + + test('should return servers matching provided IDs', async () => { + const result = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id], + }); + + expect(result.data).toHaveLength(2); + expect(result.has_more).toBe(false); + expect(result.after).toBeNull(); + }); + + test('should return empty data for empty IDs array', async () => { + const result = await methods.getListMCPServersByIds({ ids: [] }); + + expect(result.data).toEqual([]); + expect(result.has_more).toBe(false); + expect(result.after).toBeNull(); + }); + + test('should handle pagination with limit', async () => { + const result = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + limit: 2, + }); + + expect(result.data).toHaveLength(2); + expect(result.has_more).toBe(true); + expect(result.after).not.toBeNull(); + }); + + test('should paginate using cursor', async () => { + // Get first page + const firstPage = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + limit: 2, + }); + + expect(firstPage.has_more).toBe(true); + expect(firstPage.after).not.toBeNull(); + + // Get second page using cursor + const secondPage = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + limit: 2, + after: firstPage.after, + }); + + expect(secondPage.data).toHaveLength(1); + expect(secondPage.has_more).toBe(false); + expect(secondPage.after).toBeNull(); + + // Ensure no duplicates between pages + const firstPageIds = firstPage.data.map((s) => s._id.toString()); + const secondPageIds = secondPage.data.map((s) => s._id.toString()); + const intersection = firstPageIds.filter((id) => secondPageIds.includes(id)); + expect(intersection).toHaveLength(0); + }); + + test('should handle invalid cursor gracefully', async () => { + const result = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id], + after: 'invalid-cursor', + }); + + // Should still return results, ignoring invalid cursor + expect(result.data).toHaveLength(2); + }); + + test('should return all when limit is null', async () => { + const result = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + limit: null, + }); + + expect(result.data).toHaveLength(3); + expect(result.has_more).toBe(false); + expect(result.after).toBeNull(); + }); + + test('should apply additional filters via otherParams', async () => { + // Create a server with different config + const serverWithDesc = await methods.createMCPServer({ + config: createSSEConfig('Filtered Server', 'Has description'), + author: authorId, + }); + + const result = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, serverWithDesc._id], + otherParams: { 'config.description': 'Has description' }, + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].serverName).toBe('filtered-server'); + }); + + test('should normalize limit to valid range', async () => { + // Limit should be clamped to 1-100 + const resultLow = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + limit: 0, + }); + + expect(resultLow.data.length).toBeGreaterThanOrEqual(1); + + const resultHigh = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + limit: 200, + }); + + expect(resultHigh.data).toHaveLength(3); // All 3 servers (less than 100) + }); + + test('should sort by updatedAt descending, _id ascending', async () => { + const result = await methods.getListMCPServersByIds({ + ids: [server1._id, server2._id, server3._id], + }); + + expect(result.data).toHaveLength(3); + // Most recently created/updated should come first + for (let i = 0; i < result.data.length - 1; i++) { + const current = new Date(result.data[i].updatedAt!).getTime(); + const next = new Date(result.data[i + 1].updatedAt!).getTime(); + expect(current).toBeGreaterThanOrEqual(next); + } + }); + }); + + describe('updateMCPServer', () => { + test('should update server config', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Update Test', 'Original description'), + author: authorId, + }); + + const updated = await methods.updateMCPServer(created.serverName, { + config: createSSEConfig('Update Test', 'Updated description'), + }); + + expect(updated).toBeDefined(); + expect(updated?.config.description).toBe('Updated description'); + expect(updated?.serverName).toBe('update-test'); // serverName shouldn't change + }); + + test('should return null when server not found', async () => { + const updated = await methods.updateMCPServer('non-existent', { + config: createSSEConfig('Test'), + }); + + expect(updated).toBeNull(); + }); + + test('should return updated document (new: true)', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Return Test'), + author: authorId, + }); + + const updated = await methods.updateMCPServer(created.serverName, { + config: createSSEConfig('Return Test', 'New description'), + }); + + expect(updated?.config.description).toBe('New description'); + }); + + test('should run validators on update', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Validation Test'), + author: authorId, + }); + + // The update should succeed with valid config + const updated = await methods.updateMCPServer(created.serverName, { + config: createSSEConfig('Validation Test', 'Valid config'), + }); + + expect(updated).toBeDefined(); + }); + + test('should update timestamps', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Timestamp Test'), + author: authorId, + }); + + const originalUpdatedAt = created.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updated = await methods.updateMCPServer(created.serverName, { + config: createSSEConfig('Timestamp Test', 'Updated'), + }); + + expect(updated?.updatedAt).toBeDefined(); + expect(new Date(updated!.updatedAt!).getTime()).toBeGreaterThan( + new Date(originalUpdatedAt!).getTime(), + ); + }); + + test('should handle partial config updates', async () => { + const created = await methods.createMCPServer({ + config: { + type: 'sse', + url: 'https://example.com/mcp', + title: 'Partial Update Test', + description: 'Original', + }, + author: authorId, + }); + + const updated = await methods.updateMCPServer(created.serverName, { + config: { + type: 'sse', + url: 'https://example.com/mcp', + title: 'Partial Update Test', + description: 'New description', + iconPath: '/icons/new-icon.png', + }, + }); + + expect(updated?.config.description).toBe('New description'); + expect(updated?.config.iconPath).toBe('/icons/new-icon.png'); + }); + }); + + describe('deleteMCPServer', () => { + test('should delete existing server', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Delete Test'), + author: authorId, + }); + + const deleted = await methods.deleteMCPServer(created.serverName); + + expect(deleted).toBeDefined(); + expect(deleted?.serverName).toBe('delete-test'); + + // Verify it's actually deleted + const found = await methods.findMCPServerById('delete-test'); + expect(found).toBeNull(); + }); + + test('should return null when server does not exist', async () => { + const deleted = await methods.deleteMCPServer('non-existent-server'); + + expect(deleted).toBeNull(); + }); + + test('should return the deleted document', async () => { + const created = await methods.createMCPServer({ + config: createSSEConfig('Delete Return Test', 'Will be deleted'), + author: authorId, + }); + + const deleted = await methods.deleteMCPServer(created.serverName); + + expect(deleted?.config.description).toBe('Will be deleted'); + }); + }); + + describe('getListMCPServersByNames', () => { + test('should return empty data for empty names array', async () => { + const result = await methods.getListMCPServersByNames({ names: [] }); + + expect(result.data).toEqual([]); + }); + + test('should find servers by serverName strings', async () => { + await methods.createMCPServer({ + config: createSSEConfig('Name Query One'), + author: authorId, + }); + await methods.createMCPServer({ + config: createSSEConfig('Name Query Two'), + author: authorId, + }); + await methods.createMCPServer({ + config: createSSEConfig('Name Query Three'), + author: authorId, + }); + + const result = await methods.getListMCPServersByNames({ + names: ['name-query-one', 'name-query-two'], + }); + + expect(result.data).toHaveLength(2); + const serverNames = result.data.map((s) => s.serverName); + expect(serverNames).toContain('name-query-one'); + expect(serverNames).toContain('name-query-two'); + expect(serverNames).not.toContain('name-query-three'); + }); + + test('should handle non-existent names gracefully', async () => { + await methods.createMCPServer({ + config: createSSEConfig('Existing Server'), + author: authorId, + }); + + const result = await methods.getListMCPServersByNames({ + names: ['existing-server', 'non-existent-1', 'non-existent-2'], + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].serverName).toBe('existing-server'); + }); + + test('should return all matching servers for multiple names', async () => { + const server1 = await methods.createMCPServer({ + config: createSSEConfig('Multi Name 1'), + author: authorId, + }); + const server2 = await methods.createMCPServer({ + config: createSSEConfig('Multi Name 2'), + author: authorId, + }); + const server3 = await methods.createMCPServer({ + config: createSSEConfig('Multi Name 3'), + author: authorId, + }); + + const result = await methods.getListMCPServersByNames({ + names: [server1.serverName, server2.serverName, server3.serverName], + }); + + expect(result.data).toHaveLength(3); + }); + + test('should handle duplicate names in input', async () => { + await methods.createMCPServer({ + config: createSSEConfig('Duplicate Test'), + author: authorId, + }); + + const result = await methods.getListMCPServersByNames({ + names: ['duplicate-test', 'duplicate-test', 'duplicate-test'], + }); + + // Should only return one server (unique by serverName) + expect(result.data).toHaveLength(1); + }); + }); + + describe('Edge Cases', () => { + test('should handle concurrent creation with retry logic for race conditions', async () => { + // Ensure indexes are created before concurrent test + await MCPServer.ensureIndexes(); + + // Create multiple servers with same title concurrently + // The retry logic handles TOCTOU race conditions by retrying with + // exponential backoff when duplicate key errors occur + const promises = Array.from({ length: 5 }, () => + methods.createMCPServer({ + config: createSSEConfig('Concurrent Test'), + author: authorId, + }), + ); + + const results = await Promise.allSettled(promises); + + const successes = results.filter( + (r): r is PromiseFulfilledResult => r.status === 'fulfilled', + ); + const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + + // With retry logic, all concurrent requests should succeed + // Each will get a unique serverName (concurrent-test, concurrent-test-2, etc.) + expect(successes.length).toBe(5); + expect(failures.length).toBe(0); + + // Verify all servers have unique names + const serverNames = successes.map((s) => s.value.serverName); + const uniqueNames = new Set(serverNames); + expect(uniqueNames.size).toBe(5); + + // Verify all servers exist in the database + const dbServers = await MCPServer.find({ + serverName: { $regex: /^concurrent-test/ }, + }).lean(); + expect(dbServers.length).toBe(5); + }); + + test('should handle sequential creation with same title - no race condition', async () => { + // Create multiple servers with same title sequentially + // Each creation completes before the next one starts, so no race condition + const results: t.MCPServerDocument[] = []; + for (let i = 0; i < 5; i++) { + const server = await methods.createMCPServer({ + config: createSSEConfig('Sequential Test'), + author: authorId, + }); + results.push(server); + } + + // All should succeed with unique serverNames + const serverNames = results.map((r) => r.serverName); + const uniqueNames = new Set(serverNames); + expect(uniqueNames.size).toBe(5); + expect(serverNames).toContain('sequential-test'); + expect(serverNames).toContain('sequential-test-2'); + expect(serverNames).toContain('sequential-test-3'); + expect(serverNames).toContain('sequential-test-4'); + expect(serverNames).toContain('sequential-test-5'); + }); + + test('should handle very long titles', async () => { + const longTitle = 'A'.repeat(200) + ' Server'; + const config = createSSEConfig(longTitle); + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server).toBeDefined(); + expect(server.serverName).toBe('a'.repeat(200) + '-server'); + }); + + test('should handle unicode in title', async () => { + // Unicode characters should be stripped, leaving only alphanumeric + const config = createSSEConfig('Serveur Français 日本語'); + const server = await methods.createMCPServer({ config, author: authorId }); + + expect(server.serverName).toBe('serveur-franais'); + }); + + test('should handle empty string title', async () => { + const config: MCPOptions = { + type: 'sse', + url: 'https://example.com/mcp', + title: '', + }; + const server = await methods.createMCPServer({ config, author: authorId }); + + // Empty title should fallback to nanoid + expect(server.serverName).toMatch(/^mcp-[a-zA-Z0-9_-]{16}$/); + }); + + test('should handle whitespace-only title', async () => { + const config = createSSEConfig(' '); + const server = await methods.createMCPServer({ config, author: authorId }); + + // Whitespace-only title after trimming results in fallback + expect(server.serverName).toBe('mcp-server'); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/mcpServer.ts b/packages/data-schemas/src/methods/mcpServer.ts new file mode 100644 index 0000000000..4250e1db24 --- /dev/null +++ b/packages/data-schemas/src/methods/mcpServer.ts @@ -0,0 +1,324 @@ +import type { Model, RootFilterQuery, Types } from 'mongoose'; +import type { MCPServerDocument } from '../types'; +import type { MCPOptions } from 'librechat-data-provider'; +import logger from '~/config/winston'; +import { nanoid } from 'nanoid'; + +const NORMALIZED_LIMIT_DEFAULT = 20; +const MAX_CREATE_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 10; + +/** + * Helper to check if an error is a MongoDB duplicate key error. + * Since serverName is the only unique index on MCPServer, any E11000 error + * during creation is necessarily a serverName collision. + */ +function isDuplicateKeyError(error: unknown): boolean { + if (error && typeof error === 'object' && 'code' in error) { + const mongoError = error as { code: number }; + return mongoError.code === 11000; + } + return false; +} + +/** + * Escapes special regex characters in a string so they are treated literally. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Generates a URL-friendly server name from a title. + * Converts to lowercase, replaces spaces with hyphens, removes special characters. + */ +function generateServerNameFromTitle(title: string): string { + const slug = title + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') // Remove special chars except spaces and hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Remove consecutive hyphens + .replace(/^-|-$/g, ''); // Trim leading/trailing hyphens + + return slug || 'mcp-server'; // Fallback if empty +} + +export function createMCPServerMethods(mongoose: typeof import('mongoose')) { + /** + * Finds the next available server name by checking for duplicates. + * If baseName exists, returns baseName-2, baseName-3, etc. + */ + async function findNextAvailableServerName(baseName: string): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + + // Find all servers with matching base name pattern (baseName or baseName-N) + const escapedBaseName = escapeRegex(baseName); + const existing = await MCPServer.find({ + serverName: { $regex: `^${escapedBaseName}(-\\d+)?$` }, + }) + .select('serverName') + .lean(); + + if (existing.length === 0) { + return baseName; + } + + // Extract numbers from existing names + const numbers = existing.map((s) => { + const match = s.serverName.match(/-(\d+)$/); + return match ? parseInt(match[1], 10) : 1; + }); + + const maxNumber = Math.max(...numbers); + return `${baseName}-${maxNumber + 1}`; + } + + /** + * Create a new MCP server with retry logic for handling race conditions. + * When multiple requests try to create servers with the same title simultaneously, + * they may get the same serverName from findNextAvailableServerName() before any + * creates the record (TOCTOU race condition). This is handled by retrying with + * exponential backoff when a duplicate key error occurs. + * @param data - Object containing config (with title, description, url, etc.) and author + * @returns The created MCP server document + */ + async function createMCPServer(data: { + config: MCPOptions; + author: string | Types.ObjectId; + }): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + let lastError: unknown; + + for (let attempt = 0; attempt < MAX_CREATE_RETRIES; attempt++) { + try { + // Generate serverName from title, with fallback to nanoid if no title + // Important: regenerate on each attempt to get fresh available name + let serverName: string; + if (data.config.title) { + const baseSlug = generateServerNameFromTitle(data.config.title); + serverName = await findNextAvailableServerName(baseSlug); + } else { + serverName = `mcp-${nanoid(16)}`; + } + + const newServer = await MCPServer.create({ + serverName, + config: data.config, + author: data.author, + }); + + return newServer.toObject() as MCPServerDocument; + } catch (error) { + lastError = error; + + // Only retry on duplicate key errors (serverName collision) + if (isDuplicateKeyError(error) && attempt < MAX_CREATE_RETRIES - 1) { + // Exponential backoff: 10ms, 20ms, 40ms + const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt); + logger.debug( + `[createMCPServer] Duplicate serverName detected, retrying (attempt ${attempt + 2}/${MAX_CREATE_RETRIES}) after ${delay}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + // Not a duplicate key error or out of retries - throw immediately + throw error; + } + } + + // Should not reach here, but TypeScript requires a return + throw lastError; + } + + /** + * Find an MCP server by serverName + * @param serverName - The MCP server ID + * @returns The MCP server document or null + */ + async function findMCPServerById(serverName: string): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + return await MCPServer.findOne({ serverName }).lean(); + } + + /** + * Find an MCP server by MongoDB ObjectId + * @param _id - The MongoDB ObjectId + * @returns The MCP server document or null + */ + async function findMCPServerByObjectId( + _id: string | Types.ObjectId, + ): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + return await MCPServer.findById(_id).lean(); + } + + /** + * Find MCP servers by author + * @param authorId - The author's ObjectId or string + * @returns Array of MCP server documents + */ + async function findMCPServersByAuthor( + authorId: string | Types.ObjectId, + ): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + return await MCPServer.find({ author: authorId }).sort({ updatedAt: -1 }).lean(); + } + + /** + * Get a paginated list of MCP servers by IDs with filtering and search + * @param ids - Array of ObjectIds to include + * @param otherParams - Additional filter parameters (e.g., search) + * @param limit - Page size limit (null for no pagination) + * @param after - Cursor for pagination + * @returns Paginated list of MCP servers + */ + async function getListMCPServersByIds({ + ids = [], + otherParams = {}, + limit = null, + after = null, + }: { + ids?: Types.ObjectId[]; + otherParams?: RootFilterQuery; + limit?: number | null; + after?: string | null; + }): Promise<{ + data: MCPServerDocument[]; + has_more: boolean; + after: string | null; + }> { + const MCPServer = mongoose.models.MCPServer as Model; + const isPaginated = limit !== null && limit !== undefined; + const normalizedLimit = isPaginated + ? Math.min(Math.max(1, parseInt(String(limit)) || NORMALIZED_LIMIT_DEFAULT), 100) + : null; + + // Build base query combining accessible servers with other filters + const baseQuery: RootFilterQuery = { ...otherParams, _id: { $in: ids } }; + + // Add cursor condition + if (after) { + try { + const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')); + const { updatedAt, _id } = cursor; + + const cursorCondition = { + $or: [ + { updatedAt: { $lt: new Date(updatedAt) } }, + { updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } }, + ], + }; + + // Merge cursor condition with base query + if (Object.keys(baseQuery).length > 0) { + baseQuery.$and = [{ ...baseQuery }, cursorCondition]; + // Remove the original conditions from baseQuery to avoid duplication + Object.keys(baseQuery).forEach((key) => { + if (key !== '$and') { + delete baseQuery[key]; + } + }); + } + } catch (error) { + // Invalid cursor, ignore + logger.warn('[getListMCPServersByIds] Invalid cursor provided', error); + } + } + + if (normalizedLimit === null) { + // No pagination - return all matching servers + const servers = await MCPServer.find(baseQuery).sort({ updatedAt: -1, _id: 1 }).lean(); + + return { + data: servers, + has_more: false, + after: null, + }; + } + + // Paginated query - assign to const to help TypeScript + const servers = await MCPServer.find(baseQuery) + .sort({ updatedAt: -1, _id: 1 }) + .limit(normalizedLimit + 1) + .lean(); + + const hasMore = servers.length > normalizedLimit; + const data = hasMore ? servers.slice(0, normalizedLimit) : servers; + + let nextCursor = null; + if (hasMore && data.length > 0) { + const lastItem = data[data.length - 1]; + nextCursor = Buffer.from( + JSON.stringify({ + updatedAt: lastItem.updatedAt, + _id: lastItem._id, + }), + ).toString('base64'); + } + + return { + data, + has_more: hasMore, + after: nextCursor, + }; + } + + /** + * Update an MCP server + * @param serverName - The MCP server ID + * @param updateData - Object containing config to update + * @returns The updated MCP server document or null + */ + async function updateMCPServer( + serverName: string, + updateData: { config?: MCPOptions }, + ): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + return await MCPServer.findOneAndUpdate( + { serverName }, + { $set: updateData }, + { new: true, runValidators: true }, + ).lean(); + } + + /** + * Delete an MCP server + * @param serverName - The MCP server ID + * @returns The deleted MCP server document or null + */ + async function deleteMCPServer(serverName: string): Promise { + const MCPServer = mongoose.models.MCPServer as Model; + return await MCPServer.findOneAndDelete({ serverName }).lean(); + } + + /** + * Get MCP servers by their serverName strings + * @param names - Array of serverName strings to fetch + * @returns Object containing array of MCP server documents + */ + async function getListMCPServersByNames({ names = [] }: { names: string[] }): Promise<{ + data: MCPServerDocument[]; + }> { + if (names.length === 0) { + return { data: [] }; + } + const MCPServer = mongoose.models.MCPServer as Model; + const servers = await MCPServer.find({ serverName: { $in: names } }).lean(); + return { data: servers }; + } + + return { + createMCPServer, + findMCPServerById, + findMCPServerByObjectId, + findMCPServersByAuthor, + getListMCPServersByIds, + getListMCPServersByNames, + updateMCPServer, + deleteMCPServer, + }; +} + +export type MCPServerMethods = ReturnType; diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index dd1d8ee23c..45516d5a7c 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -6,6 +6,7 @@ import { createConversationModel } from './convo'; import { createMessageModel } from './message'; import { createAgentModel } from './agent'; import { createAgentCategoryModel } from './agentCategory'; +import { createMCPServerModel } from './mcpServer'; import { createRoleModel } from './role'; import { createActionModel } from './action'; import { createAssistantModel } from './assistant'; @@ -39,6 +40,7 @@ export function createModels(mongoose: typeof import('mongoose')) { Message: createMessageModel(mongoose), Agent: createAgentModel(mongoose), AgentCategory: createAgentCategoryModel(mongoose), + MCPServer: createMCPServerModel(mongoose), Role: createRoleModel(mongoose), Action: createActionModel(mongoose), Assistant: createAssistantModel(mongoose), diff --git a/packages/data-schemas/src/models/mcpServer.ts b/packages/data-schemas/src/models/mcpServer.ts new file mode 100644 index 0000000000..e2ad054068 --- /dev/null +++ b/packages/data-schemas/src/models/mcpServer.ts @@ -0,0 +1,11 @@ +import mcpServerSchema from '~/schema/mcpServer'; +import type { MCPServerDocument } from '~/types'; + +/** + * Creates or returns the MCPServer model using the provided mongoose instance and schema + */ +export function createMCPServerModel(mongoose: typeof import('mongoose')) { + return ( + mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema) + ); +} diff --git a/packages/data-schemas/src/schema/accessRole.ts b/packages/data-schemas/src/schema/accessRole.ts index 210a2b0694..082c28a5f0 100644 --- a/packages/data-schemas/src/schema/accessRole.ts +++ b/packages/data-schemas/src/schema/accessRole.ts @@ -16,7 +16,7 @@ const accessRoleSchema = new Schema( description: String, resourceType: { type: String, - enum: ['agent', 'project', 'file', 'promptGroup'], + enum: ['agent', 'project', 'file', 'promptGroup', 'mcpServer'], required: true, default: 'agent', }, diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index 716c105000..4b10572afe 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -112,6 +112,12 @@ const agentSchema = new Schema( default: false, index: true, }, + /** MCP server names extracted from tools for efficient querying */ + mcpServerNames: { + type: [String], + default: [], + index: true, + }, }, { timestamps: true, diff --git a/packages/data-schemas/src/schema/mcpServer.ts b/packages/data-schemas/src/schema/mcpServer.ts new file mode 100644 index 0000000000..8210c258d6 --- /dev/null +++ b/packages/data-schemas/src/schema/mcpServer.ts @@ -0,0 +1,31 @@ +import { Schema } from 'mongoose'; +import type { MCPServerDocument } from '~/types'; + +const mcpServerSchema = new Schema( + { + serverName: { + type: String, + index: true, + unique: true, + required: true, + }, + config: { + type: Schema.Types.Mixed, + required: true, + // Config contains: title, description, url, oauth, etc. + }, + author: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + }, + { + timestamps: true, + }, +); + +mcpServerSchema.index({ updatedAt: -1, _id: 1 }); + +export default mcpServerSchema; diff --git a/packages/data-schemas/src/schema/role.ts b/packages/data-schemas/src/schema/role.ts index eb16ddd284..e8da248c8d 100644 --- a/packages/data-schemas/src/schema/role.ts +++ b/packages/data-schemas/src/schema/role.ts @@ -53,6 +53,11 @@ const rolePermissionsSchema = new Schema( [PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: { type: Boolean }, }, + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: { type: Boolean }, + [Permissions.CREATE]: { type: Boolean }, + [Permissions.SHARE]: { type: Boolean }, + }, }, { _id: false }, ); diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 2a60635751..b150b2ec3f 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -40,4 +40,6 @@ export interface IAgent extends Omit { category: string; support_contact?: ISupportContact; is_promoted?: boolean; + /** MCP server names extracted from tools for efficient querying */ + mcpServerNames?: string[]; } diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 58122cbc55..828cdf9c8f 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -27,3 +27,5 @@ export * from './aclEntry'; export * from './group'; /* Web */ export * from './web'; +/* MCP Servers */ +export * from './mcp'; diff --git a/packages/data-schemas/src/types/mcp.ts b/packages/data-schemas/src/types/mcp.ts new file mode 100644 index 0000000000..9b1c622293 --- /dev/null +++ b/packages/data-schemas/src/types/mcp.ts @@ -0,0 +1,12 @@ +import { Document, Types } from 'mongoose'; +import type { MCPServerDB } from 'librechat-data-provider'; + +/** + * Mongoose document interface for MCP Server + * Extends API interface with Mongoose-specific database fields + */ +export interface MCPServerDocument + extends Omit, + Document { + author: Types.ObjectId; // ObjectId reference in DB (vs string in API) +} diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index a672e3fae2..679e80010f 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -51,6 +51,11 @@ export interface IRole extends Document { [PermissionTypes.FILE_CITATIONS]?: { [Permissions.USE]?: boolean; }; + [PermissionTypes.MCP_SERVERS]?: { + [Permissions.USE]?: boolean; + [Permissions.CREATE]?: boolean; + [Permissions.SHARE]?: boolean; + }; }; }