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.
This commit is contained in:
Dustin Healy 2026-06-25 07:12:46 -07:00
parent 39a06f43f4
commit ff39323fff
10 changed files with 274 additions and 29 deletions

View file

@ -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 };

View file

@ -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

View file

@ -76,9 +76,9 @@ const MCPAppView = React.memo(function MCPAppView({
}
return (
<div className="my-2" style={height ? { height } : { minHeight: 100 }}>
<div className="relative my-2" style={height ? { height } : { minHeight: 100 }}>
{!loaded && !timedOut && (
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
<div className="absolute inset-0 flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
@ -98,7 +98,7 @@ const MCPAppView = React.memo(function MCPAppView({
</div>
)}
{timedOut && !loaded && (
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
<div className="absolute inset-0 flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
{localize('com_ui_mcp_app_failed_to_load')}
</div>
)}
@ -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 ?? ''}`}
/>

View file

@ -44,7 +44,7 @@ function MCPAppCard({
return (
<>
{!loaded && (
<div className="flex h-full items-center justify-center rounded-lg border border-border-light bg-surface-secondary text-sm text-text-secondary">
<div className="absolute inset-0 flex items-center justify-center rounded-lg border border-border-light bg-surface-secondary text-sm text-text-secondary">
{localize('com_ui_loading_interactive_view')}
</div>
)}
@ -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<UIResourceCarouselProps> = React.memo(({ uiRe
animationDelay: `${index * 100}ms`,
}}
>
<div className="flex h-full flex-col">
<div className="relative flex h-full flex-col">
<MCPAppCard
resource={uiResource}
onHeightChange={(h) => handleCardHeightChange(index, h)}

View file

@ -62,11 +62,11 @@ export function MCPUIResource(props: MCPUIResourceProps) {
if (uiResource.toolName && uiResource.serverName) {
return (
<span
className="mx-1 inline-block w-full align-middle"
className="relative mx-1 inline-block w-full align-middle"
style={height ? { height } : { minHeight: '200px' }}
>
{!loaded && (
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
<div className="absolute inset-0 flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
{localize('com_ui_loading_interactive_view')}
</div>
)}
@ -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 ?? ''}`}
/>

View file

@ -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')

View file

@ -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[];

View file

@ -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<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
}): Promise<MCPConnection> {
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<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
}): Promise<unknown> {
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<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
}): Promise<unknown> {
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<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
}): Promise<unknown> {
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())) {

View file

@ -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', () => {

View file

@ -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<string, unknown> } | 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,