diff --git a/api/server/controllers/mcpApps.js b/api/server/controllers/mcpApps.js index 027064123e..96852e28d7 100644 --- a/api/server/controllers/mcpApps.js +++ b/api/server/controllers/mcpApps.js @@ -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, +}; diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 3ca4af5c87..ac1524ee3d 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -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 diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 46971a416e..a3f5e2f3d7 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -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>} */ -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, diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index 0fd461847c..a683f5e1fa 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -14,6 +14,7 @@ import { fetchMCPResourceHtml, readMCPResource, listMCPResources, + listMCPResourceTemplates, } from '~/utils/mcpApps'; import { useOptionalMessagesOperations } from '~/Providers'; import { logger } from '~/utils'; @@ -122,6 +123,9 @@ export function useAppBridge( bridge.onlistresources = async (params) => listMCPResources(resource.serverName as string, params?.cursor) as never; + bridge.onlistresourcetemplates = async (params) => + listMCPResourceTemplates(resource.serverName as string, params?.cursor) as never; + bridge.onmessage = async ({ content }) => { const text = (content as MessageContentBlock[]) .filter((block) => block.type === 'text' && typeof block.text === 'string') diff --git a/client/src/utils/mcpApps.ts b/client/src/utils/mcpApps.ts index 7c7f32b026..3c920616e1 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -81,6 +81,10 @@ export async function listMCPResources(serverName: string, cursor?: string) { return request.post(`${apiBaseUrl()}/api/mcp/resources/list`, { serverName, cursor }); } +export async function listMCPResourceTemplates(serverName: string, cursor?: string) { + return request.post(`${apiBaseUrl()}/api/mcp/resources/templates/list`, { serverName, cursor }); +} + type ResourceUiMeta = { csp?: { connectDomains?: string[]; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index 9afb9abe34..8d0921a0be 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -9,6 +9,7 @@ import { CallToolResultSchema, ReadResourceResultSchema, ListResourcesResultSchema, + ListResourceTemplatesResultSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; @@ -927,6 +928,56 @@ Please follow these instructions when using tools from the respective MCP server return result; } + async listResourceTemplates({ + userId, + serverName, + user, + cursor, + configServers, + customUserVars, + flowManager, + tokenMethods, + }: { + userId: string; + serverName: string; + user?: import('@librechat/data-schemas').IUser; + cursor?: string; + configServers?: Record; + customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; + }): Promise { + const logPrefix = `[MCP][User: ${userId}][${serverName}]`; + if (userId && user) this.updateUserLastActivity(userId); + const connection = await this.getAppConnection({ + serverName, + userId, + user, + configServers, + customUserVars, + flowManager, + tokenMethods, + }); + + if (!(await connection.isConnected())) { + throw new McpError( + ErrorCode.InternalError, + `${logPrefix} Connection is not active. Cannot list resource templates.`, + ); + } + + const result = await connection.client.request( + { + method: 'resources/templates/list', + params: cursor != null ? { cursor } : {}, + }, + ListResourceTemplatesResultSchema, + { timeout: connection.timeout }, + ); + + return result; + } + /** * Proxies a tool call from an MCP App iframe to the MCP server. * Unlike callTool, this is a lightweight proxy without provider formatting.