mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 04:42:11 +00:00
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.
156 lines
5.5 KiB
JavaScript
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 };
|