import { Types } from 'mongoose'; import { ResourceType, AccessRoleIds, PrincipalType, PermissionBits, } from 'librechat-data-provider'; import { logger, encryptV2, decryptV2, createMethods } from '@librechat/data-schemas'; import type { AllMethods, MCPServerDocument } from '@librechat/data-schemas'; import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface'; import type { ParsedServerConfig, AddServerResult } from '~/mcp/types'; import { AccessControlService } from '~/acl/accessControlService'; /** * Regex patterns for credential/env placeholders that should not be allowed in user-provided configs. * These would substitute server credentials or the CALLING user's data, creating exfiltration risks * when MCP servers are shared between users. * * Safe placeholders like {{MCP_API_KEY}} are allowed as they resolve from the user's own plugin auth. */ const DANGEROUS_CREDENTIAL_PATTERNS = [ /\$\{[^}]+\}/g, /\{\{LIBRECHAT_OPENID_[^}]+\}\}/g, /\{\{LIBRECHAT_USER_[^}]+\}\}/g, /\{\{LIBRECHAT_GRAPH_[^}]+\}\}/g, /\{\{LIBRECHAT_BODY_[^}]+\}\}/g, ]; /** * Sanitizes headers by removing dangerous credential placeholders. * This prevents credential exfiltration when MCP servers are shared between users. * * @param headers - The headers object to sanitize * @returns Sanitized headers with dangerous placeholders removed */ function sanitizeCredentialPlaceholders( headers?: Record, ): Record | undefined { if (!headers) { return headers; } const sanitized: Record = {}; for (const [key, value] of Object.entries(headers)) { let sanitizedValue = value; for (const pattern of DANGEROUS_CREDENTIAL_PATTERNS) { sanitizedValue = sanitizedValue.replace(pattern, ''); } sanitized[key] = sanitizedValue; } return sanitized; } /** * DB backed config storage * Handles CRUD Methods of dynamic mcp servers * Will handle Permission ACL */ 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); } /** * 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) { /** Publicly accessible agents */ accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({ resourceType: ResourceType.AGENT, requiredPermissions: PermissionBits.VIEW, }); } else { /** User-accessible agents */ accessibleAgentIds = await this._aclService.findAccessibleResources({ userId, requiredPermissions: PermissionBits.VIEW, resourceType: ResourceType.AGENT, }); } if (accessibleAgentIds.length === 0) { return false; } 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 sanitizedConfig = { ...config, headers: sanitizeCredentialPlaceholders( (config as ParsedServerConfig & { headers?: Record }).headers, ), } as ParsedServerConfig; /** Transformed user-provided API key config (adds customUserVars and headers) */ const transformedConfig = this.transformUserApiKeyConfig(sanitizedConfig); /** Encrypted config before storing in database */ const encryptedConfig = await this.encryptConfig(transformedConfig); const createdServer = await this._dbMethods.createMCPServer({ config: encryptedConfig, 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: await 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 { if (!userId) { throw new Error( '[ServerConfigsDB.update] User ID is required to update a database-stored MCP server.', ); } const existingServer = await this._dbMethods.findMCPServerByServerName(serverName); let configToSave: ParsedServerConfig = { ...config, headers: sanitizeCredentialPlaceholders( (config as ParsedServerConfig & { headers?: Record }).headers, ), } as ParsedServerConfig; /** Transformed user-provided API key config (adds customUserVars and headers) */ configToSave = this.transformUserApiKeyConfig(configToSave); /** Encrypted config before storing in database */ configToSave = await this.encryptConfig(configToSave); if (!config.oauth?.client_secret && existingServer?.config?.oauth?.client_secret) { configToSave = { ...configToSave, oauth: { ...configToSave.oauth, client_secret: existingServer.config.oauth.client_secret, }, }; } if ( config.apiKey?.source === 'admin' && !config.apiKey?.key && existingServer?.config?.apiKey?.source === 'admin' && existingServer?.config?.apiKey?.key ) { configToSave = { ...configToSave, apiKey: { source: configToSave.apiKey!.source, authorization_type: configToSave.apiKey!.authorization_type, custom_header: configToSave.apiKey?.custom_header, key: existingServer.config.apiKey.key, }, }; } await this._dbMethods.updateMCPServer(serverName, { config: configToSave }); } /** * 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] 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 { const server = await this._dbMethods.findMCPServerByServerName(serverName); if (!server) return undefined; 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 await this.mapDBServerToParsedConfig(server); } const hasAgentAccess = await this.hasAccessViaAgent(serverName); if (hasAgentAccess) { logger.debug( `[ServerConfigsDB.get] accessing ${serverName} via public agent (consumeOnly)`, ); return { ...(await this.mapDBServerToParsedConfig(server)), consumeOnly: true, }; } return undefined; } 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 await 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 { ...(await this.mapDBServerToParsedConfig(server)), consumeOnly: true, }; } return undefined; } /** * Return all DB stored configs (scoped by user Id if provided) * @param userId optional user id. if not provided only publicly shared mcp configs will be returned * @returns record of parsed configs */ public async getAll(userId?: string): Promise> { 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, }); } let agentMCPServerNames: string[] = []; let accessibleAgentIds: Types.ObjectId[] = []; if (!userId) { accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({ resourceType: ResourceType.AGENT, requiredPermissions: PermissionBits.VIEW, }); } else { accessibleAgentIds = await this._aclService.findAccessibleResources({ userId, requiredPermissions: PermissionBits.VIEW, resourceType: ResourceType.AGENT, }); } if (accessibleAgentIds.length > 0) { const Agent = this._mongoose.model('Agent'); const agentsWithMCP = await Agent.find( { _id: { $in: accessibleAgentIds }, mcpServerNames: { $exists: true, $not: { $size: 0 } }, }, { mcpServerNames: 1 }, ).lean(); agentMCPServerNames = [ ...new Set( // eslint-disable-next-line @typescript-eslint/no-explicit-any agentsWithMCP.flatMap((a: any) => a.mcpServerNames || []), ), ]; } const directResults = await this._dbMethods.getListMCPServersByIds({ ids: directlyAccessibleMCPIds, }); const parsedConfigs: Record = {}; const directData = directResults.data || []; const directServerNames = new Set(directData.map((s: MCPServerDocument) => s.serverName)); const directParsed = await Promise.all( directData.map((s: MCPServerDocument) => this.mapDBServerToParsedConfig(s)), ); directData.forEach((s: MCPServerDocument, i: number) => { parsedConfigs[s.serverName] = directParsed[i]; }); const agentOnlyServerNames = agentMCPServerNames.filter((name) => !directServerNames.has(name)); if (agentOnlyServerNames.length > 0) { const agentServers = await this._dbMethods.getListMCPServersByNames({ names: agentOnlyServerNames, }); const agentData = agentServers.data || []; const agentParsed = await Promise.all( agentData.map((s: MCPServerDocument) => this.mapDBServerToParsedConfig(s)), ); agentData.forEach((s: MCPServerDocument, i: number) => { parsedConfigs[s.serverName] = { ...agentParsed[i], 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. * Decrypts sensitive fields (oauth.client_secret) after retrieval. */ private async mapDBServerToParsedConfig( serverDBDoc: MCPServerDocument, ): Promise { const config: ParsedServerConfig = { ...serverDBDoc.config, dbId: (serverDBDoc._id as Types.ObjectId).toString(), updatedAt: serverDBDoc.updatedAt?.getTime(), }; return await this.decryptConfig(config); } /** * Transforms user-provided API key config by auto-generating customUserVars and headers. * This is a config transformation, not encryption. * @param config - The server config to transform * @returns The transformed config with customUserVars and headers set up */ private transformUserApiKeyConfig(config: ParsedServerConfig): ParsedServerConfig { if (!config.apiKey || config.apiKey.source !== 'user') { return config; } const result = { ...config }; const headerName = result.apiKey!.authorization_type === 'custom' ? result.apiKey!.custom_header || 'X-Api-Key' : 'Authorization'; let headerValue: string; if (result.apiKey!.authorization_type === 'basic') { headerValue = 'Basic {{MCP_API_KEY}}'; } else if (result.apiKey!.authorization_type === 'bearer') { headerValue = 'Bearer {{MCP_API_KEY}}'; } else { headerValue = '{{MCP_API_KEY}}'; } result.customUserVars = { ...result.customUserVars, MCP_API_KEY: { title: 'API Key', description: 'Your API key for this MCP server', }, }; /** Cast to access headers property (not available on Stdio type) */ const resultWithHeaders = result as ParsedServerConfig & { headers?: Record; }; resultWithHeaders.headers = { ...resultWithHeaders.headers, [headerName]: headerValue, }; // Remove key field since it's user-provided (destructure to omit, not set to undefined) const { key: _removed, ...apiKeyWithoutKey } = result.apiKey!; result.apiKey = apiKeyWithoutKey; return result; } /** * Encrypts sensitive fields in config before database storage. * Encrypts oauth.client_secret and apiKey.key (when source === 'admin'). * Throws on failure to prevent storing plaintext secrets. */ private async encryptConfig(config: ParsedServerConfig): Promise { let result = { ...config }; if (result.apiKey?.source === 'admin' && result.apiKey.key) { try { result.apiKey = { ...result.apiKey, key: await encryptV2(result.apiKey.key), }; } catch (error) { logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt apiKey.key', error); throw new Error('Failed to encrypt MCP server configuration'); } } if (result.oauth?.client_secret) { try { result = { ...result, oauth: { ...result.oauth, client_secret: await encryptV2(result.oauth.client_secret), }, }; } catch (error) { logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt client_secret', error); throw new Error('Failed to encrypt MCP server configuration'); } } return result; } /** * Decrypts sensitive fields in config after database retrieval. * Decrypts oauth.client_secret and apiKey.key (when source === 'admin'). * Returns config without secret on failure (graceful degradation). */ private async decryptConfig(config: ParsedServerConfig): Promise { let result = { ...config }; if (result.apiKey?.source === 'admin' && result.apiKey.key) { try { result.apiKey = { ...result.apiKey, key: await decryptV2(result.apiKey.key), }; } catch (error) { logger.warn( '[ServerConfigsDB.decryptConfig] Failed to decrypt apiKey.key, returning config without key', error, ); const { key: _removedKey, ...apiKeyWithoutKey } = result.apiKey; result.apiKey = apiKeyWithoutKey; } } if (result.oauth?.client_secret) { const oauthConfig = result.oauth as { client_secret: string } & typeof result.oauth; try { result = { ...result, oauth: { ...oauthConfig, client_secret: await decryptV2(oauthConfig.client_secret), }, }; } catch (error) { logger.warn( '[ServerConfigsDB.decryptConfig] Failed to decrypt client_secret, returning config without secret', error, ); const { client_secret: _removed, ...oauthWithoutSecret } = oauthConfig; result = { ...result, oauth: oauthWithoutSecret, }; } } return result; } }