LibreChat/api/server/controllers/mcp.js
Dustin Healy 054fa4bfa7
🥽 fix: Restrict MCP Server URL Disclosure to Admins, Owners, and Editors (#13784)
* 🥽 fix: Redact Non-User-Sourced MCP Server URLs by ACL Edit Permission

GET /api/mcp/servers and GET /api/mcp/servers/:serverName return MCP server configs to any caller with MCP-use permission. For user-sourced configs (DB-stored, UI-submitted), the URL is the caller's own and is intentionally disclosed. For non-user-sourced configs (YAML or config-tier, operator-defined), the URL and OAuth flow endpoints (authorization_url, token_url) are operator-sensitive: they can encode internal infrastructure hostnames and are not editable through the API.

This change redacts those fields on non-user-sourced configs unless the caller has edit authority on the resource, using the same ACL check (PermissionBits.EDIT) that the PATCH and DELETE routes already enforce via canAccessMCPServerResource. Callers with broad MANAGE_MCP_SERVERS capability bypass the per-resource check, matching the existing capability bypass in canAccessResource. customUserVars is intentionally not redacted: its values are UI hint metadata (title, description, sensitive), not user-supplied secrets; blanking it would give non-editor callers a Configure form with no field labels.

* 🥽 fix: Correct getResourcePermissionsMap import path + tighten redact comments

The MCP server redaction commit imported getResourcePermissionsMap from ~/server/controllers/PermissionsController, but that controller is a consumer of the helper, not its exporter. The canonical export lives in ~/server/services/PermissionService (which controllers/agents/v1.js already imports from). Fixes the runtime getResourcePermissionsMap is not a function failure on GET /api/mcp/servers and the four downstream route-spec failures whose config mocks lacked a source field and were therefore wrongly treated as non-user-sourced; mocks now reflect the real registry behavior (addServer/updateServer tag DB-stored configs with source: 'user'). Trims narrating JSDoc on the redact helpers and resorts the librechat-data-provider destructure by length.

* chore: import order

* 🥽 fix: Redact OAuth Revocation Endpoint Alongside Authorization And Token URLs

The OAuth-URL strip path only dropped authorization_url and token_url. The UserOAuthOptionsSchema in packages/data-provider/src/mcp.ts (line 146) accepts revocation_endpoint as another operator-configurable URL, and the OAuth handler uses it to revoke tokens; it can hold the same internal IdP hostnames the existing strip is trying to hide. Adds revocation_endpoint to the destructure so a non-user-sourced YAML/config MCP server config no longer leaks the revocation URL to non-editor callers. The existing strip url and oauth flow URLs spec is extended with a revocation_endpoint value to lock in the new field.

* 🥽 fix: Gate Shared DB Server URL Disclosure On ACL Edit Permission

source-driven URL disclosure was incorrect for shared DB-backed MCP servers. ServerConfigsDB.mapDBServerToParsedConfig (packages/api/src/mcp/registry/db/ServerConfigsDB.ts:465) sets source: 'user' on every DB-stored config it returns, regardless of who is accessing it. A user with only VIEW share on a DB server, or with agent-mediated access, was therefore treated by the redaction layer as if they owned the URL, and GET /api/mcp/servers disclosed the owner's URL and OAuth flow URLs to viewers who could not edit the resource.

The redaction is now driven purely by ACL edit authority: computeCanEditByServer routes every dbId-bearing config through PermissionBits.EDIT regardless of source; redactServerSecrets strips on !canEdit regardless of source. POST and PATCH controllers explicitly pass canEdit: true since both endpoints establish edit authority (POST creates the resource, PATCH is gated on the EDIT middleware). Legacy/ephemeral configs without a dbId still fall back to the source heuristic.

* 📝 docs: correct redactServerSecrets URL-disclosure comment

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-06-16 11:20:52 -04:00

494 lines
16 KiB
JavaScript

/**
* 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, SystemCapabilities } = require('@librechat/data-schemas');
const {
checkAccess,
isUserSourced,
MCPErrorCodes,
redactServerSecrets,
redactAllServerSecrets,
isMCPDomainNotAllowedError,
isMCPInspectionFailedError,
} = require('@librechat/api');
const {
Constants,
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
MCP_USER_INPUT_FIELDS,
MCPServerUserInputSchema,
} = require('librechat-data-provider');
const {
resolveConfigServers,
resolveMcpConfigNames,
resolveAllMcpConfigs,
} = require('~/server/services/MCP');
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
const { getResourcePermissionsMap } = require('~/server/services/PermissionService');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
const db = require('~/models');
/**
* Handles MCP-specific errors and sends appropriate HTTP responses.
* @param {Error} error - The error to handle
* @param {import('express').Response} res - Express response object
* @returns {import('express').Response | null} Response if handled, null if not an MCP error
*/
function handleMCPError(error, res) {
if (isMCPDomainNotAllowedError(error)) {
return res.status(error.statusCode).json({
error: error.code,
message: error.message,
});
}
if (isMCPInspectionFailedError(error)) {
return res.status(error.statusCode).json({
error: error.code,
message: error.message,
});
}
// Fallback for legacy string-based error handling (backwards compatibility)
if (error.message?.startsWith(MCPErrorCodes.DOMAIN_NOT_ALLOWED)) {
return res.status(403).json({
error: MCPErrorCodes.DOMAIN_NOT_ALLOWED,
message: error.message.replace(/^MCP_DOMAIN_NOT_ALLOWED\s*:\s*/i, ''),
});
}
if (error.message?.startsWith(MCPErrorCodes.INSPECTION_FAILED)) {
return res.status(400).json({
error: MCPErrorCodes.INSPECTION_FAILED,
message: error.message,
});
}
return null;
}
/**
* Get all MCP tools available to the user.
*/
const getMCPTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getMCPTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
const configuredServers = Object.keys(mcpConfig);
if (!configuredServers.length) {
return res.status(200).json({ servers: {} });
}
const mcpManager = getMCPManager();
const mcpServers = {};
const serverToolsMap = new Map();
const cacheResults = await Promise.all(
configuredServers.map(async (serverName) => {
try {
return {
serverName,
tools: await getMCPServerTools(userId, serverName, mcpConfig[serverName]),
};
} catch (error) {
logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error);
return { serverName, tools: null };
}
}),
);
for (const { serverName, tools } of cacheResults) {
if (tools) {
serverToolsMap.set(serverName, tools);
continue;
}
let serverTools;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
}
if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue;
}
serverToolsMap.set(serverName, serverTools);
if (Object.keys(serverTools).length > 0) {
// Cache asynchronously without blocking
cacheMCPServerTools({
userId,
serverName,
serverTools,
serverConfig: mcpConfig[serverName],
}).catch((err) =>
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
);
}
}
// Process each configured server
for (const serverName of configuredServers) {
try {
const serverTools = serverToolsMap.get(serverName);
const serverConfig = mcpConfig[serverName];
const server = {
name: serverName,
icon: serverConfig?.iconPath || '',
authenticated: true,
authConfig: [],
tools: [],
};
// Set authentication config once for the server
if (serverConfig?.customUserVars) {
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length > 0) {
server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
sensitive: value.sensitive,
}));
server.authenticated = false;
}
}
// Process tools efficiently - no need for convertMCPToolToPlugin
if (serverTools) {
for (const [toolKey, toolData] of Object.entries(serverTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const toolName = toolKey.split(Constants.mcp_delimiter)[0];
server.tools.push({
name: toolName,
pluginKey: toolKey,
description: toolData.function.description || '',
});
}
}
// Only add server if it has tools or is configured
if (server.tools.length > 0 || serverConfig) {
mcpServers[serverName] = server;
}
} catch (error) {
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
}
}
res.status(200).json({ servers: mcpServers });
} catch (error) {
logger.error('[getMCPTools]', error);
res.status(500).json({ message: error.message });
}
};
/** Mirrors canAccessResource's capability bypass plus per-resource ACL EDIT check. */
async function computeCanEditByServer(req, serverConfigs) {
const canEditByServer = new Map();
let bypass = false;
try {
bypass = await hasCapability(req.user, SystemCapabilities.MANAGE_MCP_SERVERS);
} catch (err) {
logger.warn(`[computeCanEditByServer] Capability bypass check failed: ${err.message}`);
}
if (bypass) {
for (const name of Object.keys(serverConfigs)) {
canEditByServer.set(name, true);
}
return canEditByServer;
}
const dbIdsToCheck = [];
const dbIdToServerName = new Map();
for (const [name, config] of Object.entries(serverConfigs)) {
if (config.dbId) {
dbIdsToCheck.push(config.dbId);
dbIdToServerName.set(String(config.dbId), name);
continue;
}
canEditByServer.set(name, isUserSourced(config));
}
if (dbIdsToCheck.length > 0) {
try {
const permsMap = await getResourcePermissionsMap({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.MCPSERVER,
resourceIds: dbIdsToCheck,
});
for (const [dbIdStr, name] of dbIdToServerName) {
const bits = permsMap.get(dbIdStr) ?? 0;
canEditByServer.set(name, (bits & PermissionBits.EDIT) !== 0);
}
} catch (err) {
logger.warn(
`[computeCanEditByServer] ACL lookup failed, defaulting to no edit: ${err.message}`,
);
for (const name of dbIdToServerName.values()) {
canEditByServer.set(name, false);
}
}
}
return canEditByServer;
}
/**
* Get all MCP servers with permissions
* @route GET /api/mcp/servers
*/
const getMCPServersList = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
const canEditByServer = await computeCanEditByServer(req, serverConfigs);
return res.json(redactAllServerSecrets(serverConfigs, { canEditByServer }));
} catch (error) {
logger.error('[getMCPServersList]', error);
res.status(500).json({ error: error.message });
}
};
/**
* Returns true when the request body's parsed config configures OBO. We block
* non-permission holders from creating or updating any DB-stored MCP server
* that mints per-user delegated tokens.
*/
function configHasObo(parsedConfig) {
return (
!!parsedConfig &&
typeof parsedConfig === 'object' &&
'obo' in parsedConfig &&
parsedConfig.obo != null
);
}
/**
* Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist).
* Any field not on this list is locked: changes to it (add, modify, or remove)
* require the permission. Allowlisting is fail-closed — when upstream introduces
* a new MCP server config field, it lands in the locked set by default until
* explicitly opted in here. Anything that could redirect the OBO token flow
* (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`,
* `apiKey`, `customUserVars`) MUST stay locked.
*/
const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']);
/**
* Returns true when any non-allowlisted user-input field differs between the
* existing server config and the new payload. Treats add, remove, and modify
* as changes (stable JSON compare, with absence on either side counting as a
* change unless both sides are absent). The comparison surface is
* `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`),
* so new fields on the schema are picked up automatically and stay locked
* by default until added to the allowlist above.
*/
function violatesOboLockdown(existingConfig, newConfig) {
for (const field of MCP_USER_INPUT_FIELDS) {
if (OBO_USER_EDITABLE_FIELDS.has(field)) continue;
const existing = existingConfig?.[field];
const next = newConfig?.[field];
if (existing === undefined && next === undefined) continue;
if (JSON.stringify(existing) !== JSON.stringify(next)) {
return true;
}
}
return false;
}
async function callerCanConfigureObo(req) {
return checkAccess({
req,
user: req.user,
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.CONFIGURE_OBO],
getRoleByName: db.getRoleByName,
});
}
/**
* 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,
});
}
if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
logger.warn(
`[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const reservedServerNames = await resolveMcpConfigNames(req);
const result = await getMCPServersRegistry().addServer(
'temp_server_name',
validation.data,
'DB',
userId,
reservedServerNames,
);
res.status(201).json({
serverName: result.serverName,
...redactServerSecrets(result.config, { canEdit: true }),
});
} catch (error) {
logger.error('[createMCPServer]', error);
const mcpErrorResponse = handleMCPError(error, res);
if (mcpErrorResponse) {
return mcpErrorResponse;
}
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 configServers = await resolveConfigServers(req);
const parsedConfig = await getMCPServersRegistry().getServerConfig(
serverName,
userId,
configServers,
);
if (!parsedConfig) {
return res.status(404).json({ message: 'MCP server not found' });
}
const canEditMap = await computeCanEditByServer(req, { [serverName]: parsedConfig });
const canEdit = canEditMap.get(serverName) ?? false;
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit }));
} 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,
});
}
/**
* On an existing OBO server, lock down every user-input field except the
* cosmetic allowlist (title, description, iconPath) for callers without
* CONFIGURE_OBO. This closes the OBO redirect vector — without it, a user
* with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`)
* to point OBO-minted tokens at an attacker-controlled endpoint. Adds,
* modifies, and removes are all caught.
*/
const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) {
if (violatesOboLockdown(existingConfig, validation.data)) {
logger.warn(
`[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
} else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
// Adding OBO to a non-OBO server (or first-time configuration) still
// requires the permission, even if existing has no OBO.
logger.warn(
`[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
);
return res
.status(403)
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
}
const parsedConfig = await getMCPServersRegistry().updateServer(
serverName,
validation.data,
'DB',
userId,
);
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true }));
} catch (error) {
logger.error('[updateMCPServer]', error);
const mcpErrorResponse = handleMCPError(error, res);
if (mcpErrorResponse) {
return mcpErrorResponse;
}
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,
};