fix(mcp): proxy resource templates and fail closed on app config resolution

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.
This commit is contained in:
Dustin Healy 2026-06-25 14:42:14 -07:00
parent f31dacac70
commit acc0befd0b
6 changed files with 128 additions and 5 deletions

View file

@ -23,10 +23,11 @@ const MCP_INVALID_REQUEST = -32600;
*/
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([
Promise.resolve()
.then(() => resolveConfigServers(req))
.catch(() => undefined),
resolveConfigServers(req, { throwOnError: true }),
Promise.resolve()
.then(() => getUserMCPAuthMap({ userId, servers: [serverName], findPluginAuthsByKeys }))
.catch(() => undefined),
@ -116,6 +117,44 @@ const listMCPResources = async (req, res) => {
}
};
/** @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 {
@ -210,4 +249,10 @@ const serveMCPSandbox = async (_req, res) => {
}
};
module.exports = { readMCPResource, listMCPResources, appToolCall, serveMCPSandbox };
module.exports = {
readMCPResource,
listMCPResources,
listMCPResourceTemplates,
appToolCall,
serveMCPSandbox,
};

View file

@ -34,6 +34,7 @@ const {
const {
readMCPResource,
listMCPResources,
listMCPResourceTemplates,
appToolCall,
serveMCPSandbox,
} = require('~/server/controllers/mcpApps');
@ -999,6 +1000,17 @@ router.post('/resources/read', requireJwtAuth, checkMCPUsePermissions, readMCPRe
*/
router.post('/resources/list', requireJwtAuth, checkMCPUsePermissions, listMCPResources);
/**
* List resource templates available on an MCP server
* @route POST /api/mcp/resources/templates/list
*/
router.post(
'/resources/templates/list',
requireJwtAuth,
checkMCPUsePermissions,
listMCPResourceTemplates,
);
/**
* Proxy tool calls from MCP App iframe to MCP server
* @route POST /api/mcp/app-tool-call

View file

@ -115,14 +115,21 @@ async function getAppConfigForUser(userId, user) {
* Resolves config-source MCP servers from admin Config overrides for the current
* request context. Returns the parsed configs keyed by server name.
* @param {import('express').Request} req - Express request with user context
* @param {{ throwOnError?: boolean }} [options] - When throwOnError is set, a resolution failure
* rejects instead of degrading to an empty set. Callers that route a request to a specific
* server (app follow-up requests) must fail closed so a transient error cannot silently fall
* back to the base config for the same server name.
* @returns {Promise<Record<string, import('@librechat/api').ParsedServerConfig>>}
*/
async function resolveConfigServers(req) {
async function resolveConfigServers(req, { throwOnError = false } = {}) {
try {
const registry = getMCPServersRegistry();
const appConfig = await getAppConfigForRequest(req);
return await registry.ensureConfigServers(appConfig?.mcpConfig || {});
} catch (error) {
if (throwOnError) {
throw error;
}
logger.warn(
'[resolveConfigServers] Failed to resolve config servers, degrading to empty:',
error,