mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
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.
284 lines
9.4 KiB
JavaScript
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,
|
|
};
|