Root cause
Authentication & access control
🧩 refactor: Decouple MCP Config from Startup Config (#10689)
AI-assisted change: GitHub Copilot
How AI contributed: Flawed AI-written codePublished Jun 2, 2026 in
danny-avila/librechat
Information disclosure (CVE-2026-44653, CVSS 6.5, CWE-200/CWE-522): in LibreChat <= 0.8.3, GET /api/mcp/servers and GET /api/mcp/servers/:serverName returned the full MCP server config objects, including decrypted admin-managed secrets (apiKey.key and oauth.client_secret), to any authenticated user with only MCP USE or VIEW permission - including viewers of a shared MCP server who do not own it. The responses were raw registry/parsed configs with zero redaction, so non-owners could exfiltrate the underlying provider/API credentials. Fixed in 0.8.4 by redacting secrets from all server responses and validating configs.
Root cause
🧩 refactor: Decouple MCP Config from Startup Config (#10689)
AI-assisted change: GitHub Copilot
Fix
🪪 fix: MCP API Responses and OAuth Validation (#12217)
Fix by Danny Avila · no AI marker found
Case facts
@@ -1,4 +1,5 @@-import type { MCPOptions } from '../mcp';+import { PermissionBits } from '../accessPermissions';+import type { MCPOptions, MCPServerUserInput } from '../mcp'; /** * Base MCP Server interface@@ -19,3 +20,33 @@ export interface IMCPServerDB { * Similar to Agent type - includes populated author fields */ export type MCPServerDB = IMCPServerDB;++/**+ * Parameters for creating a new user-managed MCP server+ * Note: Only UI-editable fields are allowed (excludes server-managed fields)+ */+export type MCPServerCreateParams = {+ config: MCPServerUserInput; // UI fields only (title, description, url, oauth, iconPath)+};++/**+ * Parameters for updating an existing user-managed MCP server+ * Note: Only UI-editable fields are allowed (excludes server-managed fields)+ */+export type MCPServerUpdateParams = {+ config?: MCPServerUserInput; // UI fields only (title, description, url, oauth, iconPath)+};++/**+ * Response for MCP server list endpoint+ */+export type MCPServerDBObjectResponse = {+ _id?: string;+ mcp_id?: string;+ author?: string | null;+ createdAt?: Date;+ updatedAt?: Date;+ effectivePermissions?: PermissionBits;+} & MCPOptions;++export type MCPServersListResponse = Record<string, MCPServerDBObjectResponse>;@@ -57,6 +57,8 @@ export enum QueryKeys { resourcePermissions = 'resourcePermissions', effectivePermissions = 'effectivePermissions', graphToken = 'graphToken',+ /* MCP Servers */+ mcpServers = 'mcpServers', } // Dynamic query keys that require parameters@@ -6,6 +6,7 @@ import * as ag from './types/agents'; import * as m from './types/mutations'; import * as q from './types/queries'; import * as f from './types/files';+import * as mcp from './types/mcpServers'; import * as config from './config'; import request from './request'; import * as s from './schemas';@@ -538,6 +539,18 @@ export const deleteAgentAction = async ({ }), ); +/**+ * MCP Servers+ */++/**+ *+ * Ensure and List loaded mcp server configs from the cache Enriched with effective permissions.+ */+export const getMCPServers = async (): Promise<mcp.MCPServersListResponse> => {+ return request.get(endpoints.mcp.servers);+};+ /** * Imports a conversations file. *@@ -1,6 +1,66 @@ import { Constants } from 'librechat-data-provider';+import type { ParsedServerConfig } from '~/mcp/types'; export const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);++/**+ * Allowlist-based sanitization for API responses. Only explicitly listed fields are included;+ * new fields added to ParsedServerConfig are excluded by default until allowlisted here.+ *+ * URLs are returned as-is: DB-stored configs reject ${VAR} patterns at validation time+ * (MCPServerUserInputSchema), and YAML configs are admin-managed. Env variable resolution+ * is handled at the schema/input boundary, not the output boundary.+ */+export function redactServerSecrets(config: ParsedServerConfig): Partial<ParsedServerConfig> {+ const safe: Partial<ParsedServerConfig> = {+ type: config.type,+ url: config.url,+ title: config.title,+ description: config.description,+ iconPath: config.iconPath,+ chatMenu: config.chatMenu,+ requiresOAuth: config.requiresOAuth,+ capabilities: config.capabilities,+ tools: config.tools,+ toolFunctions: config.toolFunctions,+ initDuration: config.initDuration,+ updatedAt: config.updatedAt,+ dbId: config.dbId,+ consumeOnly: config.consumeOnly,+ inspectionFailed: config.inspectionFailed,+ customUserVars: config.customUserVars,+ serverInstructions: config.serverInstructions,+ };++ if (config.apiKey) {+ safe.apiKey = {+ source: config.apiKey.source,+ authorization_type: config.apiKey.authorization_type,+ ...(config.apiKey.custom_header && { custom_header: config.apiKey.custom_header }),+ };+ }++ if (config.oauth) {+ const { client_secret: _secret, ...safeOAuth } = config.oauth;+ safe.oauth = safeOAuth;+ }++ return Object.fromEntries(+ Object.entries(safe).filter(([, v]) => v !== undefined),+ ) as Partial<ParsedServerConfig>;+}++/** Applies allowlist-based sanitization to a map of server configs. */+export function redactAllServerSecrets(+ configs: Record<string, ParsedServerConfig>,+): Record<string, Partial<ParsedServerConfig>> {+ const result: Record<string, Partial<ParsedServerConfig>> = {};+ for (const [key, config] of Object.entries(configs)) {+ result[key] = redactServerSecrets(config);+ }+ return result;+}+ /** * Normalizes a server name to match the pattern ^[a-zA-Z0-9_.-]+$ * This is required for Azure OpenAI models with Tool Calling@@ -24,6 +24,7 @@ import { selectRegistrationAuthMethod, inferClientAuthMethod, } from './methods';+import { isSSRFTarget, resolveHostnameSSRF } from '~/auth'; import { sanitizeUrlForLogging } from '~/mcp/utils'; /** Type for the OAuth metadata from the SDK */@@ -144,7 +145,9 @@ export class MCPOAuthHandler { resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {}, fetchFn); if (resourceMetadata?.authorization_servers?.length) {- authServerUrl = new URL(resourceMetadata.authorization_servers[0]);+ const discoveredAuthServer = resourceMetadata.authorization_servers[0];+ await this.validateOAuthUrl(discoveredAuthServer, 'authorization_server');+ authServerUrl = new URL(discoveredAuthServer); logger.debug( `[MCPOAuth] Found authorization server from resource metadata: ${authServerUrl}`, );@@ -200,6 +203,19 @@ export class MCPOAuthHandler { logger.debug(`[MCPOAuth] OAuth metadata discovered successfully`); const metadata = await OAuthMetadataSchema.parseAsync(rawMetadata); + const endpointChecks: Promise<void>[] = [];+ if (metadata.registration_endpoint) {+ endpointChecks.push(+ this.validateOAuthUrl(metadata.registration_endpoint, 'registration_endpoint'),+ );+ }+ if (metadata.token_endpoint) {+ endpointChecks.push(this.validateOAuthUrl(metadata.token_endpoint, 'token_endpoint'));+ }+ if (endpointChecks.length > 0) {+ await Promise.all(endpointChecks);+ }+ logger.debug(`[MCPOAuth] OAuth metadata parsed successfully`); return { metadata: metadata as unknown as OAuthMetadata,@@ -355,10 +371,14 @@ export class MCPOAuthHandler { logger.debug(`[MCPOAuth] Generated flowId: ${flowId}, state: ${state}`); try {- // Check if we have pre-configured OAuth settings if (config?.authorization_url && config?.token_url && config?.client_id) { logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`); + await Promise.all([+ this.validateOAuthUrl(config.authorization_url, 'authorization_url'),+ this.validateOAuthUrl(config.token_url, 'token_url'),+ ]);+ const skipCodeChallengeCheck = config?.skip_code_challenge_check === true || process.env.MCP_SKIP_CODE_CHALLENGE_CHECK === 'true';@@ -410,10 +430,11 @@ export class MCPOAuthHandler { code_challenge_methods_supported: codeChallengeMethodsSupported, }; logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`);+ const redirectUri = this.getDefaultRedirectUri(serverName); const clientInfo: OAuthClientInformation = { client_id: config.client_id, client_secret: config.client_secret,- redirect_uris: [config.redirect_uri || this.getDefaultRedirectUri(serverName)],+ redirect_uris: [redirectUri], scope: config.scope, token_endpoint_auth_method: tokenEndpointAuthMethod, };@@ -422,7 +443,7 @@ export class MCPOAuthHandler { const { authorizationUrl, codeVerifier } = await startAuthorization(serverUrl, { metadata: metadata as unknown as SDKOAuthMetadata, clientInformation: clientInfo,- redirectUrl: clientInfo.redirect_uris?.[0] || this.getDefaultRedirectUri(serverName),+ redirectUrl: redirectUri, scope: config.scope, }); @@ -462,8 +483,7 @@ export class MCPOAuthHandler { `[MCPOAuth] OAuth metadata discovered, auth server URL: ${sanitizeUrlForLogging(authServerUrl)}`, ); - /** Dynamic client registration based on the discovered metadata */- const redirectUri = config?.redirect_uri || this.getDefaultRedirectUri(serverName);+ const redirectUri = this.getDefaultRedirectUri(serverName); logger.debug(`[MCPOAuth] Registering OAuth client with redirect URI: ${redirectUri}`); const clientInfo = await this.registerOAuthClient(@@ -672,6 +692,24 @@ export class MCPOAuthHandler { return randomBytes(32).toString('base64url'); }@@ -50,6 +50,18 @@ const router = Router(); const OAUTH_CSRF_COOKIE_PATH = '/api/mcp'; +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 all MCP tools available to the user * Returns only MCP tools, completely decoupled from regular LibreChat tools@@ -470,69 +482,75 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => { * Reinitialize MCP server * This endpoint allows reinitializing a specific MCP server */-router.post('/:serverName/reinitialize', requireJwtAuth, setOAuthSession, async (req, res) => {- try {- const { serverName } = req.params;- const user = createSafeUser(req.user);+router.post(+ '/:serverName/reinitialize',+ requireJwtAuth,+ checkMCPUsePermissions,+ setOAuthSession,+ async (req, res) => {+ try {+ const { serverName } = req.params;+ const user = createSafeUser(req.user); - if (!user.id) {- return res.status(401).json({ error: 'User not authenticated' });- }+ if (!user.id) {+ return res.status(401).json({ error: 'User not authenticated' });+ } - logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);+ logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`); - const mcpManager = getMCPManager();- const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);- if (!serverConfig) {- return res.status(404).json({- error: `MCP server '${serverName}' not found in configuration`,- });- }+ const mcpManager = getMCPManager();+ const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);+ if (!serverConfig) {+ return res.status(404).json({+ error: `MCP server '${serverName}' not found in configuration`,+ });+ } - await mcpManager.disconnectUserConnection(user.id, serverName);- logger.info(- `[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,- );+ await mcpManager.disconnectUserConnection(user.id, serverName);+ logger.info(+ `[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,+ ); - /** @type {Record<string, Record<string, string>> | undefined} */- let userMCPAuthMap;- if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {- userMCPAuthMap = await getUserMCPAuthMap({- userId: user.id,- servers: [serverName],- findPluginAuthsByKeys,+ /** @type {Record<string, Record<string, string>> | undefined} */+ let userMCPAuthMap;+ if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {+ userMCPAuthMap = await getUserMCPAuthMap({+ userId: user.id,+ servers: [serverName],+ findPluginAuthsByKeys,+ });+ }++ const result = await reinitMCPServer({+ user,AI-assisted change be70de63eec873e92c85d5dec197a8928f69efd6d37658308dda1e6c80cce4d6 · Fix 0eae7472f24c0307e50423f1f12a5904e575573c7119285e4dd9e981d9c83635