mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-03 12:54:01 +00:00
The host advertises serverResources, and the ext-apps bridge treats resources/read, resources/list, and resources/templates/list as one proxied set. Only the first two were wired, so an app that sent resources/templates/list received a method-not-found. Register an onlistresourcetemplates handler backed by a new MCPManager.listResourceTemplates and a /api/mcp/resources/templates/list route, mirroring the existing resources/list path. Tool listing is left out deliberately: the App Bridge has no app-to-host tools/list request, and serverTools covers only tool calls. Make app follow-up requests fail closed when scoped config resolution errors. resolveConfigServers gains an opt-in throwOnError so the app path rejects instead of degrading to an empty set, which previously let a transient failure fall back to the base config for the same server name and proxy the iframe request to the wrong server.
258 lines
8.5 KiB
JavaScript
258 lines
8.5 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 { 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' });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
readMCPResource,
|
|
listMCPResources,
|
|
listMCPResourceTemplates,
|
|
appToolCall,
|
|
serveMCPSandbox,
|
|
};
|