LibreChat/api/server/controllers/mcpApps.js
Dustin Healy 251b18b9e9 fix(mcp): resolve config and credential context for app follow-up requests
Plumbs the request-scoped config and user credentials into the app endpoints so
config-sourced servers resolve and credentialed connections work even after the
original tool-call connection is gone. The readResource and app-tool-call
controllers now resolve configServers and the user's customUserVars and pass
them through to getAppConnection, which forwards configServers to
getServerConfig and customUserVars to both the connection factory and the header
refresh. Header refresh now runs for customUserVar configs when the route
supplied those vars, and is still skipped when they are absent so a live
connection's resolved headers are never clobbered with bare placeholders.

Prefers the resource item's own _meta.ui csp and permissions for embedded ui://
resources, falling back to tool-level metadata, so a resource that declares its
own connect or resource domains is not served the default restrictive policy.

Stops caching app-initiated resource reads. The five-minute cache now applies
only to the immutable app HTML fetch; bridge onreadresource calls bypass it so
dynamic resources are not served stale.

Advertises MCP Apps support during MCP initialize. The client now sends the
ext-apps capability (mimeTypes including text/html;profile=mcp-app) so servers
using the getUiCapability graceful-degradation path expose app-enhanced tools
rather than text-only fallbacks.
2026-06-24 08:13:32 -07:00

156 lines
5.5 KiB
JavaScript

const path = require('path');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const { getUserMCPAuthMap } = require('@librechat/api');
const { getMCPManager } = require('~/config');
const { resolveConfigServers } = require('~/server/services/MCP');
const { findPluginAuthsByKeys } = require('~/models');
// MCP SDK ErrorCode.InvalidRequest = -32600
const MCP_INVALID_REQUEST = -32600;
/**
* Resolves the request-scoped config and the user's custom variables for a server so app
* follow-up requests can connect to config-sourced servers and re-resolve credentialed headers
* even when the original tool-call connection is gone.
*/
const resolveAppContext = async (req, serverName) => {
const userId = req.user?.id;
const [configServers, userMCPAuthMap] = await Promise.all([
Promise.resolve()
.then(() => resolveConfigServers(req))
.catch(() => undefined),
Promise.resolve()
.then(() => getUserMCPAuthMap({ userId, servers: [serverName], findPluginAuthsByKeys }))
.catch(() => undefined),
]);
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
return { configServers, customUserVars };
};
/** @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 } = await resolveAppContext(req, serverName);
const result = await mcpManager.readResource({
userId,
serverName,
uri,
user: req.user,
configServers,
customUserVars,
});
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/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 } = await resolveAppContext(req, serverName);
const result = await mcpManager.appToolCall({
userId,
serverName,
toolName,
toolArguments: toolArgs || {},
user: req.user,
configServers,
customUserVars,
});
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' });
}
};
module.exports = { readMCPResource, appToolCall, serveMCPSandbox };