From ff39323fff5536e784dbe52fe85c6666a7f53e3e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:12:46 -0700 Subject: [PATCH] fix(mcp): OAuth-aware app connections, list proxy, unique result ids, laid-out iframes Plumbs OAuth context into app follow-up requests. The app controllers now build a flowManager and tokenMethods and pass them through readResource, listResources, and appToolCall to getAppConnection and getConnection, so a cold-recreated connection (idle timeout, restart, reload) for an OAuth-backed server reuses the user's stored tokens instead of failing for lack of OAuth context. Backs the advertised serverResources capability with resource listing. Apps that feature-detect serverResources can call resources/list, which had no handler. A new listResources manager method, a POST /api/mcp/resources/list route, and an onlistresources bridge handler proxy listing the same way reads are proxied. Makes synthetic and embedded app resource ids unique per result snapshot. The id now mixes in the tool result content, _meta, and error state alongside the resourceUri, structuredContent, and arguments, so repeated calls that differ only in those fields no longer collide and overwrite earlier conversation resources. Keeps app iframes laid out while waiting for size. The frame is rendered transparent until a positive size event instead of display:none, with the loading state overlaid, so an app whose initial auto-resize reports zero is not stuck behind the spinner. --- api/server/controllers/mcpApps.js | 77 +++++++++++++++--- api/server/routes/mcp.js | 13 ++- .../Chat/Messages/Content/ToolCall.tsx | 8 +- .../Messages/Content/UIResourceCarousel.tsx | 6 +- .../MCPUIResource/MCPUIResource.tsx | 6 +- client/src/hooks/MCP/useAppBridge.ts | 10 ++- client/src/utils/mcpApps.ts | 4 + packages/api/src/mcp/MCPManager.ts | 73 +++++++++++++++++ .../api/src/mcp/__tests__/MCPManager.test.ts | 79 +++++++++++++++++++ packages/api/src/mcp/parsers.ts | 27 +++++-- 10 files changed, 274 insertions(+), 29 deletions(-) diff --git a/api/server/controllers/mcpApps.js b/api/server/controllers/mcpApps.js index 8a05227ea7..027064123e 100644 --- a/api/server/controllers/mcpApps.js +++ b/api/server/controllers/mcpApps.js @@ -1,18 +1,25 @@ const path = require('path'); const { logger } = require('@librechat/data-schemas'); -const { Constants } = require('librechat-data-provider'); +const { CacheKeys, Constants } = require('librechat-data-provider'); const { getUserMCPAuthMap } = require('@librechat/api'); -const { getMCPManager } = require('~/config'); +const { getMCPManager, getFlowStateManager } = require('~/config'); const { resolveConfigServers } = require('~/server/services/MCP'); -const { findPluginAuthsByKeys } = require('~/models'); +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 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. + * 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; @@ -25,7 +32,9 @@ const resolveAppContext = async (req, serverName) => { .catch(() => undefined), ]); const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; - return { configServers, customUserVars }; + const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS)); + const tokenMethods = { findToken, createToken, updateToken, deleteTokens }; + return { configServers, customUserVars, flowManager, tokenMethods }; }; /** @route POST /api/mcp/resources/read */ @@ -48,7 +57,10 @@ const readMCPResource = async (req, res) => { } const mcpManager = getMCPManager(); - const { configServers, customUserVars } = await resolveAppContext(req, serverName); + const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext( + req, + serverName, + ); const result = await mcpManager.readResource({ userId, serverName, @@ -56,6 +68,8 @@ const readMCPResource = async (req, res) => { user: req.user, configServers, customUserVars, + flowManager, + tokenMethods, }); return res.json(result); } catch (error) { @@ -64,6 +78,44 @@ const readMCPResource = async (req, res) => { } }; +/** @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/app-tool-call */ const appToolCall = async (req, res) => { try { @@ -85,7 +137,10 @@ const appToolCall = async (req, res) => { } const mcpManager = getMCPManager(); - const { configServers, customUserVars } = await resolveAppContext(req, serverName); + const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext( + req, + serverName, + ); const result = await mcpManager.appToolCall({ userId, serverName, @@ -94,6 +149,8 @@ const appToolCall = async (req, res) => { user: req.user, configServers, customUserVars, + flowManager, + tokenMethods, }); return res.json(result); } catch (error) { @@ -153,4 +210,4 @@ const serveMCPSandbox = async (_req, res) => { } }; -module.exports = { readMCPResource, appToolCall, serveMCPSandbox }; +module.exports = { readMCPResource, listMCPResources, appToolCall, serveMCPSandbox }; diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index d4b821388c..3ca4af5c87 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -31,7 +31,12 @@ const { getMCPServerById, getMCPTools, } = require('~/server/controllers/mcp'); -const { readMCPResource, appToolCall, serveMCPSandbox } = require('~/server/controllers/mcpApps'); +const { + readMCPResource, + listMCPResources, + appToolCall, + serveMCPSandbox, +} = require('~/server/controllers/mcpApps'); const mcpAppToolCallLimiter = require('~/server/middleware/limiters/mcpAppToolCallLimiter'); const { getOAuthReconnectionManager, @@ -988,6 +993,12 @@ router.delete( */ router.post('/resources/read', requireJwtAuth, checkMCPUsePermissions, readMCPResource); +/** + * List resources available on an MCP server + * @route POST /api/mcp/resources/list + */ +router.post('/resources/list', requireJwtAuth, checkMCPUsePermissions, listMCPResources); + /** * Proxy tool calls from MCP App iframe to MCP server * @route POST /api/mcp/app-tool-call diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index a5c6b96e6e..d1015217c1 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -76,9 +76,9 @@ const MCPAppView = React.memo(function MCPAppView({ } return ( -
+
{!loaded && !timedOut && ( -
+
)} {timedOut && !loaded && ( -
+
{localize('com_ui_mcp_app_failed_to_load')}
)} @@ -110,7 +110,7 @@ const MCPAppView = React.memo(function MCPAppView({ width: '100%', height: '100%', border: 'none', - display: loaded ? 'block' : 'none', + opacity: loaded ? 1 : 0, }} title={`MCP App: ${app.toolName ?? ''}`} /> diff --git a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx index 637fa54188..b0b5a26f66 100644 --- a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx +++ b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx @@ -44,7 +44,7 @@ function MCPAppCard({ return ( <> {!loaded && ( -
+
{localize('com_ui_loading_interactive_view')}
)} @@ -56,7 +56,7 @@ function MCPAppCard({ width: '100%', height: '100%', border: 'none', - display: loaded ? 'block' : 'none', + opacity: loaded ? 1 : 0, }} title={`MCP App: ${resource.toolName ?? ''}`} /> @@ -181,7 +181,7 @@ const UIResourceCarousel: React.FC = React.memo(({ uiRe animationDelay: `${index * 100}ms`, }} > -
+
handleCardHeightChange(index, h)} diff --git a/client/src/components/MCPUIResource/MCPUIResource.tsx b/client/src/components/MCPUIResource/MCPUIResource.tsx index 5016a0718b..5973c33ba3 100644 --- a/client/src/components/MCPUIResource/MCPUIResource.tsx +++ b/client/src/components/MCPUIResource/MCPUIResource.tsx @@ -62,11 +62,11 @@ export function MCPUIResource(props: MCPUIResourceProps) { if (uiResource.toolName && uiResource.serverName) { return ( {!loaded && ( -
+
{localize('com_ui_loading_interactive_view')}
)} @@ -78,7 +78,7 @@ export function MCPUIResource(props: MCPUIResourceProps) { width: '100%', height: '100%', border: 'none', - display: loaded ? 'block' : 'none', + opacity: loaded ? 1 : 0, }} title={`MCP App: ${uiResource.toolName ?? ''}`} /> diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index bee136a589..0ad90f82a8 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -7,7 +7,12 @@ import { } from '@modelcontextprotocol/ext-apps/app-bridge'; import type { UIResource } from 'librechat-data-provider'; import type { AppToolResult } from '~/utils/mcpApps'; -import { callMCPAppTool, fetchMCPResourceHtml, readMCPResource } from '~/utils/mcpApps'; +import { + callMCPAppTool, + fetchMCPResourceHtml, + readMCPResource, + listMCPResources, +} from '~/utils/mcpApps'; import { useOptionalMessagesOperations } from '~/Providers'; import { logger } from '~/utils'; import store from '~/store'; @@ -108,6 +113,9 @@ export function useAppBridge( bridge.onreadresource = async (params) => readMCPResource(resource.serverName as string, params.uri, user?.id) as never; + bridge.onlistresources = async (params) => + listMCPResources(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 ff120f200f..45773e890e 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -102,6 +102,10 @@ export async function readMCPResource( return promise; } +export async function listMCPResources(serverName: string, cursor?: string) { + return request.post(`${apiBaseUrl()}/api/mcp/resources/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 ca4d6babdd..0f5d95a2f8 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -8,6 +8,7 @@ import { import { CallToolResultSchema, ReadResourceResultSchema, + ListResourcesResultSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; @@ -721,12 +722,16 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }: { serverName: string; userId: string; user?: IUser; configServers?: Record; customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; const rawConfig = await MCPServersRegistry.getInstance().getServerConfig( @@ -762,6 +767,8 @@ Please follow these instructions when using tools from the respective MCP server user, serverConfig: rawConfig ?? undefined, customUserVars, + flowManager, + tokenMethods, }); // Refresh headers when the config can be fully resolved: env-var-only configs always, and @@ -791,6 +798,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }: { userId: string; serverName: string; @@ -798,6 +807,8 @@ Please follow these instructions when using tools from the respective MCP server user?: import('@librechat/data-schemas').IUser; configServers?: Record; customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); @@ -807,6 +818,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }); if (!(await connection.isConnected())) { @@ -828,6 +841,60 @@ Please follow these instructions when using tools from the respective MCP server return result; } + /** + * Proxies an MCP App resources/list request to the server. Paired with readResource so the + * advertised serverResources capability is fully backed (resource-browser apps need listing). + */ + async listResources({ + 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 resources.`, + ); + } + + const result = await connection.client.request( + { + method: 'resources/list', + params: cursor != null ? { cursor } : {}, + }, + ListResourcesResultSchema, + { 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. @@ -840,6 +907,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }: { userId: string; serverName: string; @@ -848,6 +917,8 @@ Please follow these instructions when using tools from the respective MCP server user?: import('@librechat/data-schemas').IUser; configServers?: Record; customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); @@ -857,6 +928,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }); if (!(await connection.isConnected())) { diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 52d5758c49..7fc9f4ffec 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -1276,6 +1276,85 @@ describe('MCPManager', () => { Authorization: 'Bearer secret', }); }); + + it('forwards configServers, flowManager, and tokenMethods to getConnection', async () => { + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + source: 'yaml', + type: 'sse', + url: 'https://example.com/mcp', + }); + + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + fetchTools: jest.fn().mockResolvedValue([{ name: 'do_thing', _meta: {} }]), + timeout: 30000, + client: { request: jest.fn().mockResolvedValue({ content: [] }) }, + } as unknown as MCPConnection; + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const getConnectionSpy = jest + .spyOn(manager, 'getConnection') + .mockResolvedValue(mockConnection); + + const flowManager = {} as never; + const tokenMethods = {} as never; + const configServers = { 'cfg-server': { type: 'sse', url: 'https://x' } } as never; + + await manager.appToolCall({ + userId: 'user-123', + serverName: 'cfg-server', + toolName: 'do_thing', + toolArguments: {}, + user: mockUser as IUser, + configServers, + flowManager, + tokenMethods, + }); + + expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith( + 'cfg-server', + 'user-123', + configServers, + ); + expect(getConnectionSpy).toHaveBeenCalledWith( + expect.objectContaining({ flowManager, tokenMethods }), + ); + }); + + it('proxies resources/list through the app connection', async () => { + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + source: 'yaml', + type: 'sse', + url: 'https://example.com/mcp', + }); + + const request = jest.fn().mockResolvedValue({ resources: [{ uri: 'file://a' }] }); + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + fetchTools: jest.fn().mockResolvedValue([]), + timeout: 30000, + client: { request }, + } as unknown as MCPConnection; + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getConnection').mockResolvedValue(mockConnection); + + const result = await manager.listResources({ + userId: 'user-123', + serverName: 'srv', + user: mockUser as IUser, + cursor: 'next', + }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'resources/list', params: { cursor: 'next' } }), + expect.anything(), + expect.anything(), + ); + expect(result).toEqual({ resources: [{ uri: 'file://a' }] }); + }); }); describe('getConnection', () => { diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts index 9ac41a1ea0..d33edbf393 100644 --- a/packages/api/src/mcp/parsers.ts +++ b/packages/api/src/mcp/parsers.ts @@ -9,6 +9,24 @@ function generateResourceId(text: string): string { return crypto.createHash('sha256').update(text).digest('hex').substring(0, 10); } +/** + * Derives a UI resource ID that is unique per result snapshot. The frontend indexes conversation + * resources by ID, so two calls that share a base (resourceUri/text) and args but differ in + * structuredContent, text content, _meta, or error state must not collide and overwrite each other. + */ +function deriveResourceId(base: string, result: t.MCPToolCallResponse, toolArgs: unknown): string { + const meta = (result as { _meta?: unknown } | undefined)?._meta; + const parts = [ + base, + result?.structuredContent != null ? JSON.stringify(result.structuredContent) : '', + result?.content != null ? JSON.stringify(result.content) : '', + meta != null ? JSON.stringify(meta) : '', + result?.isError === true ? '1' : '', + toolArgs != null ? JSON.stringify(toolArgs) : '', + ]; + return generateResourceId(parts.join('\x00')); +} + function getMCPImageDataMaxBytes(): number { const raw = process.env.MCP_IMAGE_DATA_MAX_BYTES; if (!raw) { @@ -198,10 +216,7 @@ export function formatToolContent( 'text' in item.resource && item.resource.text && typeof item.resource.text === 'string' ? item.resource.text : item.resource.uri; - const scKey = - result?.structuredContent != null ? JSON.stringify(result.structuredContent) : ''; - const argsKey = metadata?.toolArgs != null ? JSON.stringify(metadata.toolArgs) : ''; - const resourceId = generateResourceId(baseHash + '\x00' + scKey + '\x00' + argsKey); + const resourceId = deriveResourceId(baseHash, result, metadata?.toolArgs); const itemUi = (item.resource._meta as { ui?: Record } | undefined)?.ui as | { csp?: UIResource['csp']; permissions?: UIResource['permissions'] } | undefined; @@ -256,9 +271,7 @@ export function formatToolContent( metadata.serverName && metadata.toolName ) { - const scKey = result?.structuredContent != null ? JSON.stringify(result.structuredContent) : ''; - const argsKey = metadata.toolArgs != null ? JSON.stringify(metadata.toolArgs) : ''; - const resourceId = generateResourceId(metadata.resourceUri + '\x00' + scKey + '\x00' + argsKey); + const resourceId = deriveResourceId(metadata.resourceUri, result, metadata.toolArgs); uiResources.push({ resourceId, uri: metadata.resourceUri,