LibreChat/api/server/controllers/mcpApps.js
Dustin Healy ea75afc99a fix(mcp): harden MCP Apps host security and CJS compatibility
Reimplement the MCP Apps ui-meta helpers (RESOURCE_MIME_TYPE, getToolUiResourceUri,
isToolVisibilityModelOnly, isToolVisibilityAppOnly) in packages/api/src/mcp/apps.ts so
@librechat/api no longer imports the ESM-only @modelcontextprotocol/ext-apps from its CommonJS
build. ext-apps remains a client-only dependency, removing the require(ESM) boundary that throws
ERR_REQUIRE_ESM on Node versions without synchronous require(esm) support.

Add an mcpSettings.apps toggle (enabled unless explicitly false). Thread enableApps through
connection creation so the io.modelcontextprotocol/ui capability is advertised only when apps are
enabled, and gate the resource and app-tool-call routes with a requireMCPAppsEnabled middleware.

Authorize app-driven resources/read against the resources and templates a server advertises, so a
sandboxed app cannot proxy arbitrary uris. ui:// resources stay allowed and the check fails closed.

Render MCP apps in shared and search transcripts display-only by withholding the host-bound bridge
handlers and capabilities in read-only views, so an embedded app cannot call tools or read
resources with the viewer's auth while the stored tool result still renders.
2026-06-28 21:56:28 -07:00

284 lines
9.4 KiB
JavaScript

const path = require('path');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getUserMCPAuthMap } = 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, the user's custom variables, and the OAuth flow/token
* context for a server so app follow-up requests can connect to config-sourced servers and
* re-resolve credentialed or OAuth connections even when the original tool-call connection is gone.
*/
const resolveAppContext = async (req, serverName) => {
const userId = req.user?.id;
// Fail closed on config resolution: an app request targets one server by name, so a transient
// failure must reject rather than fall back to the base config for that name and proxy to the
// wrong server. Auth map resolution may still degrade, since a missing var fails closed downstream.
const [configServers, userMCPAuthMap] = await Promise.all([
resolveConfigServers(req, { throwOnError: true }),
Promise.resolve()
.then(() => getUserMCPAuthMap({ userId, servers: [serverName], findPluginAuthsByKeys }))
.catch(() => undefined),
]);
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;
if (!serverName || !uri) {
return res.status(400).json({ error: 'serverName and uri are required' });
}
// The serverResources capability lets an app read any resource the connected MCP server
// exposes (ui:// templates plus supporting data such as file:// or custom schemes), so the
// proxy only requires a non-empty string and leaves resource authorization to the server.
if (typeof uri !== 'string' || uri.length === 0) {
return res.status(400).json({ error: 'uri must be a non-empty string' });
}
const mcpManager = getMCPManager();
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
req,
serverName,
);
const result = await mcpManager.readResource({
userId,
serverName,
uri,
user: req.user,
configServers,
customUserVars,
flowManager,
tokenMethods,
});
return res.json(result);
} catch (error) {
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;
if (!serverName) {
return res.status(400).json({ error: 'serverName is required' });
}
if (cursor !== undefined && typeof cursor !== 'string') {
return res.status(400).json({ error: 'cursor must be a string' });
}
const mcpManager = getMCPManager();
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
req,
serverName,
);
const result = await mcpManager.listResources({
userId,
serverName,
user: req.user,
cursor,
configServers,
customUserVars,
flowManager,
tokenMethods,
});
return res.json(result);
} catch (error) {
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;
if (!serverName) {
return res.status(400).json({ error: 'serverName is required' });
}
if (cursor !== undefined && typeof cursor !== 'string') {
return res.status(400).json({ error: 'cursor must be a string' });
}
const mcpManager = getMCPManager();
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
req,
serverName,
);
const result = await mcpManager.listResourceTemplates({
userId,
serverName,
user: req.user,
cursor,
configServers,
customUserVars,
flowManager,
tokenMethods,
});
return res.json(result);
} catch (error) {
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;
if (!serverName || !toolName) {
return res.status(400).json({ error: 'serverName and toolName are required' });
}
if (
toolArgs !== undefined &&
toolArgs !== null &&
(typeof toolArgs !== 'object' || Array.isArray(toolArgs))
) {
return res.status(400).json({ error: 'arguments must be an object' });
}
const mcpManager = getMCPManager();
const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext(
req,
serverName,
);
const result = await mcpManager.appToolCall({
userId,
serverName,
toolName,
toolArguments: toolArgs || {},
user: req.user,
configServers,
customUserVars,
flowManager,
tokenMethods,
});
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();
if (allowedParents) {
const ancestors = allowedParents
.split(/[\s,]+/)
.filter(Boolean)
.join(' ');
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,
};