LibreChat/api/server/controllers/mcpApps.js
Dustin Healy 0f708c2eb8 fix(mcp): harden app CSP, fail closed on auth resolution, and rate-limit resource reads
Render non-app (no profile=mcp-app) ui:// HTML inert: the static srcDoc iframes in ToolCall,
MCPUIResource, and UIResourceCarousel now use sandbox="" so scripts and forms run only through the
CSP-applying sandbox proxy. Make the proxy's meta CSP unbypassable by wrapping any document whose
markup precedes <head>, so nothing untrusted is parsed before the policy takes effect.

Fail closed in resolveAppContext when MCP auth-value resolution throws, logging and rejecting rather
than proceeding with unresolved or stale credentials. Validate each MCP_SANDBOX_FRAME_ANCESTORS
token against a scheme://host[:port] pattern so a stray ";" cannot inject an extra CSP directive.

Rate-limit the app resource endpoints (resources/read, list, templates/list) per user, and correct
AppToolResult.content from an empty-tuple type to unknown[]. Add controller tests for the
frame-ancestors validation and the auth fail-closed path.
2026-06-30 17:30:56 -07:00

237 lines
8.2 KiB
JavaScript

const path = require('path');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const {
getUserMCPAuthMap,
readAppResource,
listAppResources,
listAppResourceTemplates,
callAppTool,
} = require('@librechat/api');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config');
const { resolveConfigServers } = require('~/server/services/MCP');
const {
findPluginAuthsByKeys,
findToken,
createToken,
updateToken,
deleteTokens,
} = require('~/models');
const { getLogStores } = require('~/cache');
// MCP SDK ErrorCode.InvalidRequest = -32600
const MCP_INVALID_REQUEST = -32600;
/**
* Resolves the request-scoped config and auth context so app follow-up requests can reconnect to
* config-sourced servers even when the original tool-call connection is gone.
*/
const resolveAppContext = async (req, serverName) => {
const userId = req.user?.id;
// Fail closed on both config and auth resolution: a transient lookup failure must reject rather
// than fall back to the base config (wrong server) or to unresolved/stale credentials. A user
// who genuinely has no vars resolves to undefined without throwing, so that path still proceeds.
const [configServers, userMCPAuthMap] = await Promise.all([
resolveConfigServers(req, { throwOnError: true }),
getUserMCPAuthMap({ userId, servers: [serverName], findPluginAuthsByKeys }).catch((err) => {
logger.error(
`[resolveAppContext] Failed to resolve MCP auth values for user ${userId}, server ${serverName}; failing closed`,
err,
);
throw err;
}),
]);
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const tokenMethods = { findToken, createToken, updateToken, deleteTokens };
return { configServers, customUserVars, flowManager, tokenMethods };
};
/** @route POST /api/mcp/resources/read */
const readMCPResource = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { serverName, uri } = req.body;
const ctx = {
userId,
serverName,
user: req.user,
...(await resolveAppContext(req, serverName)),
};
const result = await readAppResource(getMCPManager(), ctx, uri);
return res.json(result);
} catch (error) {
// A denied read is an expected client error, so return 400 and skip the error-level log.
if (error && typeof error === 'object' && error.code === MCP_INVALID_REQUEST) {
return res.status(400).json({ error: error.message });
}
logger.error('[readMCPResource] Error:', error);
return res.status(500).json({ error: 'Failed to read resource' });
}
};
/** @route POST /api/mcp/resources/list */
const listMCPResources = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { serverName, cursor } = req.body;
const ctx = {
userId,
serverName,
user: req.user,
...(await resolveAppContext(req, serverName)),
};
const result = await listAppResources(getMCPManager(), ctx, cursor);
return res.json(result);
} catch (error) {
if (error && typeof error === 'object' && error.code === MCP_INVALID_REQUEST) {
return res.status(400).json({ error: error.message });
}
logger.error('[listMCPResources] Error:', error);
return res.status(500).json({ error: 'Failed to list resources' });
}
};
/** @route POST /api/mcp/resources/templates/list */
const listMCPResourceTemplates = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { serverName, cursor } = req.body;
const ctx = {
userId,
serverName,
user: req.user,
...(await resolveAppContext(req, serverName)),
};
const result = await listAppResourceTemplates(getMCPManager(), ctx, cursor);
return res.json(result);
} catch (error) {
if (error && typeof error === 'object' && error.code === MCP_INVALID_REQUEST) {
return res.status(400).json({ error: error.message });
}
logger.error('[listMCPResourceTemplates] Error:', error);
return res.status(500).json({ error: 'Failed to list resource templates' });
}
};
/** @route POST /api/mcp/app-tool-call */
const appToolCall = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { serverName, toolName, arguments: toolArgs } = req.body;
const ctx = {
userId,
serverName,
user: req.user,
...(await resolveAppContext(req, serverName)),
};
const result = await callAppTool(getMCPManager(), ctx, toolName, toolArgs);
return res.json(result);
} catch (error) {
logger.error('[appToolCall] Error:', error);
if (error && typeof error === 'object' && error.code === MCP_INVALID_REQUEST) {
return res.status(400).json({ error: error.message });
}
return res.status(500).json({ error: 'Failed to execute tool' });
}
};
/** @route GET /api/mcp/sandbox */
const serveMCPSandbox = async (_req, res) => {
try {
res.setHeader('Content-Type', 'text/html');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
// The MCP Apps spec requires the Host and Sandbox to have different origins for web hosts.
// Default to same-origin framing; when a dedicated sandbox origin is deployed, the operator
// lists the allowed host origin(s) so the host page can frame this sandbox cross-origin.
const allowedParents = (process.env.MCP_SANDBOX_FRAME_ANCESTORS || '').trim();
// Only accept scheme://host[:port] tokens. A raw value is interpolated into the CSP header, so
// an unvalidated token containing ";" would inject an unrelated directive.
const ancestors = allowedParents
.split(/[\s,]+/)
.filter((token) => /^https?:\/\/[a-zA-Z0-9][a-zA-Z0-9.-]*(?::\d{1,5})?$/.test(token))
.join(' ');
if (ancestors) {
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${ancestors}`);
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
} else {
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'");
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
}
const sandboxPath = path.resolve(
__dirname,
'..',
'..',
'..',
'client',
'public',
'mcp-sandbox.html',
);
return res.sendFile(sandboxPath, (error) => {
if (error) {
logger.error('[serveMCPSandbox] Error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to load MCP sandbox' });
}
}
});
} catch (error) {
logger.error('[serveMCPSandbox] Error:', error);
return res.status(500).json({ error: 'Failed to load MCP sandbox' });
}
};
/**
* Blocks MCP App endpoints when an admin has disabled apps via `mcpSettings.apps: false`.
* Defense-in-depth alongside the connection-level capability gate: even if a server still
* advertises UI tools, the host refuses to proxy resource reads and app tool calls while off.
*/
const requireMCPAppsEnabled = async (req, res, next) => {
try {
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
if (appConfig?.mcpSettings?.apps === false) {
return res.status(403).json({ error: 'MCP Apps are disabled' });
}
return next();
} catch (error) {
logger.error('[requireMCPAppsEnabled] Error:', error);
return res.status(500).json({ error: 'Failed to resolve MCP Apps configuration' });
}
};
module.exports = {
readMCPResource,
listMCPResources,
listMCPResourceTemplates,
appToolCall,
serveMCPSandbox,
requireMCPAppsEnabled,
};