🧩 fix: Normalize MCP UI Resource Rendering (#14868)

* fix: normalize MCP UI resource rendering

* fix: filter unsupported MCP UI resources

* fix: preserve MCP UI marker examples

* fix: handle MCP UI resource edge cases

* fix: harden MCP UI marker sanitization

* fix: scope MCP UI marker sanitization

* fix: parse MCP UI marker contexts

* fix: align MCP UI sanitizer parsing

* fix: match MCP UI renderer syntax

* fix: align blockquote marker spans

* fix: decode MCP UI text node sources

* fix: sanitize nested subagent markers

* fix: bound MCP UI sanitizer traversal

* fix: keep MCP UI marker mapping linear

* style: sort security patch imports

* fix: harden nested MCP UI sanitization

* fix: mirror citation cleanup for MCP UI markers

* fix: clean decoded citation markers

* fix: clean assembled citation markers

* fix: align MCP marker sanitization with rendering

* fix: match persisted MCP marker render paths

* fix: preserve highlighted citation boundaries

* fix: align MCP markers across content renderers

* fix: preserve citation renderer boundaries

* fix: match legacy thinking trim semantics
This commit is contained in:
Danny Avila 2026-08-15 12:49:59 -04:00 committed by GitHub
parent eb3b353712
commit 1d789c41a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1758 additions and 272 deletions

View file

@ -1,13 +1,13 @@
import { useState, useMemo } from 'react';
import { ChevronDown } from 'lucide-react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import UIResourceRenderer, { isSupportedUIResource } from '~/components/MCPUIResource/Renderer';
import { useOptionalMessagesOperations } from '~/Providers';
import { useLocalize, useExpandCollapse } from '~/hooks';
import UIResourceCarousel from './UIResourceCarousel';
import { handleUIAction, cn } from '~/utils';
import { OutputRenderer } from './ToolOutput';
import { handleUIAction, cn } from '~/utils';
function isSimpleObject(obj: unknown): obj is Record<string, string | number | boolean | null> {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
@ -126,7 +126,8 @@ export default function ToolCallInfo({
?.filter((attachment) => attachment.type === Tools.ui_resources)
.flatMap((attachment) => {
return attachment[Tools.ui_resources] as UIResource[];
}) ?? [];
})
.filter(isSupportedUIResource) ?? [];
return (
<div className="w-full px-3 py-3.5">

View file

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { Button } from '@librechat/client';
import { UIResourceRenderer } from '@mcp-ui/client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceRenderer, { isSupportedUIResource } from '~/components/MCPUIResource/Renderer';
import { useOptionalMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils';
import { useLocalize } from '~/hooks';
@ -18,6 +18,10 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
const [isContainerHovered, setIsContainerHovered] = useState(false);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const { ask } = useOptionalMessagesOperations();
const supportedUIResources = React.useMemo(
() => uiResources.filter(isSupportedUIResource),
[uiResources],
);
const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return;
@ -49,12 +53,24 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}
}, [handleScroll]);
}, [handleScroll, supportedUIResources.length]);
if (uiResources.length === 0) {
if (supportedUIResources.length === 0) {
return null;
}
if (supportedUIResources.length === 1) {
return (
<UIResourceRenderer
resource={supportedUIResources[0]}
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
}}
/>
);
}
return (
<div
className="relative mb-4 pt-3"
@ -91,7 +107,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
ref={scrollContainerRef}
className="hide-scrollbar flex gap-4 overflow-x-auto scroll-smooth"
>
{uiResources.map((uiResource, index) => {
{supportedUIResources.map((uiResource, index) => {
const height = 360;
const width = 230;

View file

@ -64,8 +64,10 @@ describe('ToolCallInfo', () => {
describe('ui_resources from attachments', () => {
it('should render single ui_resource from attachments', () => {
const uiResource = {
type: 'text',
data: 'Test resource',
resourceId: 'resource-1',
uri: 'ui://test/resource-1',
mimeType: 'text/html',
text: 'Test resource',
};
const attachments: TAttachment[] = [
@ -104,10 +106,25 @@ describe('ToolCallInfo', () => {
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
] as any,
{
resourceId: 'resource-1',
uri: 'ui://test/resource-1',
mimeType: 'text/html',
text: 'Resource 1',
},
{
resourceId: 'resource-2',
uri: 'ui://test/resource-2',
mimeType: 'text/html',
text: 'Resource 2',
},
{
resourceId: 'resource-3',
uri: 'ui://test/resource-3',
mimeType: 'text/html',
text: 'Resource 3',
},
],
},
];
@ -117,9 +134,9 @@ describe('ToolCallInfo', () => {
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
expect.objectContaining({ resourceId: 'resource-1' }),
expect.objectContaining({ resourceId: 'resource-2' }),
expect.objectContaining({ resourceId: 'resource-3' }),
],
}),
expect.any(Object),
@ -129,6 +146,65 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).not.toHaveBeenCalled();
});
it('renders a single supported resource without carousel layout', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{
resourceId: 'blocked-resource',
uri: 'ui://test/blocked',
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
text: 'malicious script',
},
{
resourceId: 'html-resource',
uri: 'ui://test/html',
mimeType: 'text/html',
text: '<p>Supported</p>',
},
],
},
];
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: expect.objectContaining({ resourceId: 'html-resource' }),
}),
expect.any(Object),
);
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('omits the resource section when every resource is blocked', () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: [
{
resourceId: 'blocked-resource',
uri: 'ui://test/blocked',
mimeType: 'text/uri-list',
text: 'https://example.com',
},
],
},
];
render(<ToolCallInfo input="" attachments={attachments} />);
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
it('should handle no attachments', () => {
render(<ToolCallInfo {...mockProps} output="Some output" />);
@ -200,7 +276,14 @@ describe('ToolCallInfo', () => {
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }] as any,
[Tools.ui_resources]: [
{
resourceId: 'resource-1',
uri: 'ui://test/resource-1',
mimeType: 'text/html',
text: 'Test',
},
],
},
];
@ -208,7 +291,7 @@ describe('ToolCallInfo', () => {
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'text', data: 'Test' },
resource: expect.objectContaining({ resourceId: 'resource-1', text: 'Test' }),
}),
expect.any(Object),
);
@ -241,7 +324,14 @@ describe('ToolCallInfo', () => {
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }] as any,
[Tools.ui_resources]: [
{
resourceId: 'attachment-resource',
uri: 'ui://test/attachment-resource',
mimeType: 'text/html',
text: 'From attachments',
},
],
},
];
@ -259,7 +349,10 @@ describe('ToolCallInfo', () => {
// Should use attachments, not output
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
resource: expect.objectContaining({
resourceId: 'attachment-resource',
text: 'From attachments',
}),
}),
expect.any(Object),
);

View file

@ -1,9 +1,27 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import type { ComponentProps } from 'react';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import { handleUIAction } from '~/utils';
jest.mock(
'@librechat/client',
() => ({
Button: ({
variant: _variant,
size: _size,
...props
}: ComponentProps<'button'> & { variant?: string; size?: string }) => <button {...props} />,
}),
{ virtual: true },
);
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) =>
key === 'com_ui_scroll_left' ? 'Scroll left' : 'Scroll right',
}));
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource, onUIAction }: any) => (
@ -69,6 +87,43 @@ describe('UIResourceCarousel', () => {
expect(container.firstChild).toBeNull();
});
it('filters blocked resources before choosing a single-resource layout', () => {
const { container } = render(
<UIResourceCarousel
uiResources={[
{
resourceId: 'blocked-resource',
uri: 'ui://blocked',
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
text: 'malicious script',
},
mockUIResources[0],
]}
/>,
);
expect(screen.getAllByTestId('ui-resource-renderer')).toHaveLength(1);
expect(screen.getByText('Resource 1')).toBeInTheDocument();
expect(container.querySelector('.hide-scrollbar')).not.toBeInTheDocument();
});
it('renders nothing when every resource is blocked', () => {
const { container } = render(
<UIResourceCarousel
uiResources={[
{
resourceId: 'blocked-resource',
uri: 'ui://blocked',
mimeType: 'text/uri-list',
text: 'https://example.com',
},
]}
/>,
);
expect(container.firstChild).toBeNull();
});
it('renders all UI resources', () => {
render(<UIResourceCarousel uiResources={mockUIResources} />);
const renderers = screen.getAllByTestId('ui-resource-renderer');
@ -141,6 +196,26 @@ describe('UIResourceCarousel', () => {
});
});
it('binds scrolling when a singleton becomes a carousel', async () => {
const { container, rerender } = render(
<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />,
);
expect(container.querySelector('.hide-scrollbar')).not.toBeInTheDocument();
rerender(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
const scrollContainer = container.querySelector('.hide-scrollbar');
Object.defineProperty(scrollContainer, 'scrollLeft', {
configurable: true,
value: 200,
});
fireEvent.scroll(scrollContainer!);
await waitFor(() => {
expect(screen.getByLabelText('Scroll left')).toBeInTheDocument();
});
});
it('hides right arrow when scrolled to end', async () => {
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
const scrollContainer = container.querySelector('.hide-scrollbar');

View file

@ -1,7 +1,7 @@
import React from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import { useOptionalMessagesConversation, useOptionalMessagesOperations } from '~/Providers';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import UIResourceRenderer, { isSupportedUIResource } from './Renderer';
import { handleUIAction } from '~/utils';
import { useLocalize } from '~/hooks';
@ -34,6 +34,10 @@ export function MCPUIResource(props: MCPUIResourceProps) {
);
}
if (!isSupportedUIResource(uiResource)) {
return null;
}
try {
return (
<span className="mx-1 inline-block w-full align-middle">
@ -42,7 +46,6 @@ export function MCPUIResource(props: MCPUIResourceProps) {
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
sandboxPermissions: 'allow-popups',
}}
/>
</span>

View file

@ -0,0 +1,47 @@
import { UIResourceRenderer as LegacyUIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import type { ComponentProps } from 'react';
type LegacyRendererProps = ComponentProps<typeof LegacyUIResourceRenderer>;
type UIResourceRendererProps = Omit<
LegacyRendererProps,
'resource' | 'remoteDomProps' | 'supportedContentTypes'
> & {
resource: UIResource;
};
export function isSupportedUIResource(
resource: UIResource | null | undefined,
): resource is UIResource {
return (
typeof resource?.mimeType === 'string' &&
resource.mimeType.split(';', 1)[0].trim().toLowerCase() === 'text/html'
);
}
/** Restricts legacy MCP-UI rendering to sandboxed inline HTML resources. */
export default function UIResourceRenderer({
resource,
htmlProps,
...props
}: UIResourceRendererProps) {
if (!isSupportedUIResource(resource)) {
return null;
}
const safeResource = { ...resource };
const safeHtmlProps = { ...htmlProps };
delete safeResource.contentType;
safeResource.mimeType = 'text/html';
delete safeHtmlProps.sandboxPermissions;
return (
<LegacyUIResourceRenderer
{...props}
resource={safeResource}
htmlProps={safeHtmlProps}
supportedContentTypes={['rawHtml']}
/>
);
}

View file

@ -1,19 +1,23 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResource } from '../MCPUIResource';
import { render, screen } from '@testing-library/react';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useLocalize } from '~/hooks';
import { MCPUIResource } from '../MCPUIResource';
import { handleUIAction } from '~/utils';
import { useLocalize } from '~/hooks';
// Mock dependencies
jest.mock('~/Providers');
jest.mock('~/hooks');
jest.mock('~/utils');
jest.mock('~/Providers', () => ({
useMessageContext: jest.fn(),
useOptionalMessagesConversation: jest.fn(),
useOptionalMessagesOperations: jest.fn(),
}));
jest.mock('~/hooks', () => ({ useLocalize: jest.fn() }));
jest.mock('~/utils', () => ({ handleUIAction: jest.fn() }));
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource, onUIAction }: any) => (
@ -141,6 +145,34 @@ describe('MCPUIResource', () => {
expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
});
it('should omit a referenced resource with an unsupported MIME type', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'blocked-resource',
uri: 'ui://test/blocked',
mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
text: 'malicious script',
},
],
},
],
},
];
const { container } = renderWithRecoil(
<MCPUIResource node={{ properties: { resourceId: 'blocked-resource' } }} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId('ui-resource-renderer')).not.toBeInTheDocument();
});
it('should resolve resources by resourceId across conversation messages', () => {
mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any);
currentTestMessages = [

View file

@ -0,0 +1,98 @@
import { render, screen } from '@testing-library/react';
import { UIResourceRenderer as LegacyUIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import UIResourceRenderer from '../Renderer';
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: jest.fn(({ resource }) => (
<div data-testid="legacy-ui-resource" data-mime-type={resource.mimeType} />
)),
}));
const mockLegacyRenderer = LegacyUIResourceRenderer as jest.MockedFunction<
typeof LegacyUIResourceRenderer
>;
describe('UIResourceRenderer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
'application/vnd.mcp-ui.remote-dom+javascript',
'application/vnd.mcp-ui.remote-dom',
'text/uri-list',
])('blocks unsafe legacy MIME type %s', (mimeType) => {
const resource: UIResource = {
resourceId: 'unsafe-resource',
uri: 'ui://unsafe',
mimeType,
text: "root.innerHTML='<img src=x onerror=alert(window.origin)>'",
};
const { container } = render(<UIResourceRenderer resource={resource} />);
expect(container).toBeEmptyDOMElement();
expect(mockLegacyRenderer).not.toHaveBeenCalled();
});
it('blocks malformed non-string MIME values', () => {
const resource: UIResource = {
resourceId: 'malformed-resource',
uri: 'ui://malformed',
mimeType: 1 as unknown as string,
text: '<p>Malformed resource</p>',
};
const { container } = render(<UIResourceRenderer resource={resource} />);
expect(container).toBeEmptyDOMElement();
expect(mockLegacyRenderer).not.toHaveBeenCalled();
});
it('forces text/html through the raw HTML renderer without popup permissions', () => {
const resource: UIResource = {
resourceId: 'html-resource',
uri: 'ui://html',
mimeType: 'text/html',
contentType: 'remoteDom',
text: '<p>Safe iframe content</p>',
};
render(
<UIResourceRenderer
resource={resource}
htmlProps={{ sandboxPermissions: 'allow-popups allow-same-origin' }}
/>,
);
expect(screen.getByTestId('legacy-ui-resource')).toBeInTheDocument();
expect(mockLegacyRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: expect.not.objectContaining({ contentType: expect.anything() }),
htmlProps: {},
supportedContentTypes: ['rawHtml'],
}),
expect.any(Object),
);
});
it.each(['text/html; charset=utf-8', 'TEXT/HTML'])('normalizes HTML MIME type %s', (mimeType) => {
const resource: UIResource = {
resourceId: 'html-resource',
uri: 'ui://html',
mimeType,
text: '<p>Safe iframe content</p>',
};
render(<UIResourceRenderer resource={resource} />);
expect(mockLegacyRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: expect.objectContaining({ mimeType: 'text/html' }),
supportedContentTypes: ['rawHtml'],
}),
expect.any(Object),
);
});
});