💻 feat: Deeper MCP UI integration in the Chat UI (#9669)

* 💻 feat: deeper MCP UI integration in the chat UI using plugins

---------

Co-authored-by: Samuel Path <samuel.path@shopify.com>
Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>

* 💻 refactor: Migrate MCP UI resources from index-based to ID-based referencing

- Replace index-based resource markers with stable resource IDs
- Update plugin to parse \ui{resourceId} format instead of \ui0
- Refactor components to use useMessagesOperations instead of useSubmitMessage
- Add ShareMessagesProvider for UI resources in share view
- Add useConversationUIResources hook for cross-turn resource lookups
- Update parsers to generate resource IDs from content hashes
- Update all tests to use resource IDs instead of indices
- Add sandbox permissions for iframe popups
- Remove deprecated MCP tool context instructions

---------

Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>
This commit is contained in:
Samuel Path 2025-12-11 22:02:38 +01:00 committed by Danny Avila
parent 4a0fbb07bc
commit 304bba853c
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
27 changed files with 1545 additions and 122 deletions

View file

@ -9,6 +9,11 @@ import rehypeHighlight from 'rehype-highlight';
import remarkDirective from 'remark-directive';
import type { Pluggable } from 'unified';
import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation';
import {
mcpUIResourcePlugin,
MCPUIResource,
MCPUIResourceCarousel,
} from '~/components/MCPUIResource';
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
@ -55,6 +60,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
artifactPlugin,
[remarkMath, { singleDollarTextMath: false }],
unicodeCitation,
mcpUIResourcePlugin,
];
if (isInitializing) {
@ -86,6 +92,8 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
citation: Citation,
'highlighted-text': HighlightedText,
'composite-citation': CompositeCitation,
'mcp-ui-resource': MCPUIResource,
'mcp-ui-carousel': MCPUIResourceCarousel,
} as {
[nodeType: string]: React.ElementType;
}

View file

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import { useMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils';
interface UIResourceCarouselProps {
uiResources: UIResource[];
@ -11,6 +13,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
const [showRightArrow, setShowRightArrow] = useState(true);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const { ask } = useMessagesOperations();
const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return;
@ -111,9 +114,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
mimeType: uiResource.mimeType,
text: uiResource.text,
}}
onUIAction={async (result) => {
console.log('Action:', result);
}}
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
}}

View file

@ -0,0 +1,106 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import Markdown from '../Markdown';
import { RecoilRoot } from 'recoil';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks';
// Mocks for hooks used by MCPUIResource when rendered inside Markdown.
// Keep Provider components intact while mocking only the hooks we use.
jest.mock('~/Providers', () => ({
...jest.requireActual('~/Providers'),
useMessageContext: jest.fn(),
useMessagesConversation: jest.fn(),
useMessagesOperations: jest.fn(),
}));
jest.mock('~/data-provider');
jest.mock('~/hooks');
// Mock @mcp-ui/client to render identifiable elements for assertions
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource }: any) => (
<div data-testid="ui-resource-renderer" data-resource-uri={resource?.uri} />
),
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId
>;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
describe('Markdown with MCP UI markers (resource IDs)', () => {
let currentTestMessages: any[] = [];
beforeEach(() => {
jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg-weather' } as any);
mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv1' },
conversationId: 'conv1',
} as any);
mockUseMessagesOperations.mockReturnValue({
ask: jest.fn(),
getMessages: () => currentTestMessages,
} as any);
mockUseLocalize.mockReturnValue(((key: string) => key) as any);
});
it('renders two UIResourceRenderer components for markers with resource IDs across separate attachments', () => {
// Two tool responses, each produced one ui_resources attachment
const paris = {
resourceId: 'abc123',
uri: 'ui://weather/paris',
mimeType: 'text/html',
text: '<div>Paris Weather</div>',
};
const nyc = {
resourceId: 'def456',
uri: 'ui://weather/nyc',
mimeType: 'text/html',
text: '<div>NYC Weather</div>',
};
currentTestMessages = [
{
messageId: 'msg-weather',
attachments: [
{ type: 'ui_resources', ui_resources: [paris] },
{ type: 'ui_resources', ui_resources: [nyc] },
],
},
];
mockUseGetMessagesByConvoId.mockReturnValue({ data: currentTestMessages } as any);
const content = [
'Here are the current weather conditions for both Paris and New York:',
'',
'- Paris: Slight rain, 53°F, humidity 76%, wind 9 mph.',
'- New York: Clear sky, 63°F, humidity 91%, wind 6 mph.',
'',
`Browse these weather cards for more details ${UI_RESOURCE_MARKER}{abc123} ${UI_RESOURCE_MARKER}{def456}`,
].join('\n');
render(
<RecoilRoot>
<Markdown content={content} isLatestMessage={false} />
</RecoilRoot>,
);
const renderers = screen.getAllByTestId('ui-resource-renderer');
expect(renderers).toHaveLength(2);
expect(renderers[0]).toHaveAttribute('data-resource-uri', 'ui://weather/paris');
expect(renderers[1]).toHaveAttribute('data-resource-uri', 'ui://weather/nyc');
});
});

View file

@ -1,8 +1,8 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import { handleUIAction } from '~/utils';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({
@ -13,6 +13,19 @@ jest.mock('@mcp-ui/client', () => ({
),
}));
// Mock useMessagesOperations hook
const mockAsk = jest.fn();
jest.mock('~/Providers', () => ({
useMessagesOperations: () => ({
ask: mockAsk,
}),
}));
// Mock handleUIAction utility
jest.mock('~/utils', () => ({
handleUIAction: jest.fn(),
}));
// Mock scrollTo
const mockScrollTo = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
@ -29,8 +42,12 @@ describe('UIResourceCarousel', () => {
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
];
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
beforeEach(() => {
jest.clearAllMocks();
mockAsk.mockClear();
mockHandleUIAction.mockClear();
// Reset scroll properties
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
configurable: true,
@ -141,18 +158,48 @@ describe('UIResourceCarousel', () => {
});
});
it('handles UIResource actions', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
it('handles UIResource actions using handleUIAction', async () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
const renderer = screen.getByTestId('ui-resource-renderer');
fireEvent.click(renderer);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Action:', { action: 'test' });
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk);
});
});
it('calls handleUIAction with correct parameters for multiple resources', async () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 3)} />);
const renderers = screen.getAllByTestId('ui-resource-renderer');
// Click the second renderer
fireEvent.click(renderers[1]);
await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk);
expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
});
consoleSpy.mockRestore();
// Click the third renderer
fireEvent.click(renderers[2]);
await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledTimes(2);
});
});
it('passes correct ask function to handleUIAction', async () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
const renderer = screen.getByTestId('ui-resource-renderer');
fireEvent.click(renderer);
await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk);
expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
});
});
it('applies correct dimensions to resource containers', () => {

View file

@ -0,0 +1,63 @@
import React from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import { handleUIAction } from '~/utils';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useLocalize } from '~/hooks';
interface MCPUIResourceProps {
node: {
properties: {
resourceId: string;
};
};
}
/**
* Component that renders an MCP UI resource based on its resource ID.
* Works in both main app and share view.
*/
export function MCPUIResource(props: MCPUIResourceProps) {
const { resourceId } = props.node.properties;
const localize = useLocalize();
const { ask } = useMessagesOperations();
const { conversation } = useMessagesConversation();
const conversationResourceMap = useConversationUIResources(
conversation?.conversationId ?? undefined,
);
const uiResource = conversationResourceMap.get(resourceId ?? '');
if (!uiResource) {
return (
<span className="inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600">
{localize('com_ui_ui_resource_not_found', {
0: resourceId ?? '',
})}
</span>
);
}
try {
return (
<span className="mx-1 inline-block w-full align-middle">
<UIResourceRenderer
resource={uiResource}
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
sandboxPermissions: 'allow-popups',
}}
/>
</span>
);
} catch (error) {
console.error('Error rendering UI resource:', error);
return (
<span className="inline-flex items-center rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600">
{localize('com_ui_ui_resource_error', { 0: uiResource.name })}
</span>
);
}
}

View file

@ -0,0 +1,37 @@
import React, { useMemo } from 'react';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessagesConversation } from '~/Providers';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import type { UIResource } from 'librechat-data-provider';
interface MCPUIResourceCarouselProps {
node: {
properties: {
resourceIds?: string[];
};
};
}
/**
* Component that renders multiple MCP UI resources in a carousel.
* Works in both main app and share view.
*/
export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) {
const { conversation } = useMessagesConversation();
const conversationResourceMap = useConversationUIResources(
conversation?.conversationId ?? undefined,
);
const uiResources = useMemo(() => {
const { resourceIds = [] } = props.node.properties;
return resourceIds.map((id) => conversationResourceMap.get(id)).filter(Boolean) as UIResource[];
}, [props.node.properties, conversationResourceMap]);
if (uiResources.length === 0) {
return null;
}
return <UIResourceCarousel uiResources={uiResources} />;
}

View file

@ -0,0 +1,276 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResource } from '../MCPUIResource';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useLocalize } from '~/hooks';
import { handleUIAction } from '~/utils';
// Mock dependencies
jest.mock('~/Providers');
jest.mock('~/hooks');
jest.mock('~/utils');
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource, onUIAction }: any) => (
<div
data-testid="ui-resource-renderer"
data-resource-uri={resource?.uri}
onClick={() => onUIAction({ action: 'test' })}
/>
),
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
>;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
describe('MCPUIResource', () => {
const mockLocalize = (key: string, values?: any) => {
const translations: Record<string, string> = {
com_ui_ui_resource_not_found: `UI resource ${values?.[0]} not found`,
com_ui_ui_resource_error: `Error rendering UI resource: ${values?.[0]}`,
};
return translations[key] || key;
};
const mockAskFn = jest.fn();
const renderWithRecoil = (ui: React.ReactNode) => render(<RecoilRoot>{ui}</RecoilRoot>);
// Store the current test's messages so getMessages can return them
let currentTestMessages: any[] = [];
beforeEach(() => {
jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv123' },
conversationId: 'conv123',
} as any);
mockUseMessagesOperations.mockReturnValue({
ask: mockAskFn,
getMessages: () => currentTestMessages,
regenerate: jest.fn(),
handleContinue: jest.fn(),
setMessages: jest.fn(),
} as any);
mockUseLocalize.mockReturnValue(mockLocalize as any);
});
describe('resource fetching', () => {
it('should fetch and render UI resource from message attachments', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'resource-1',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Test Resource</p>',
},
],
},
],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource');
});
it('should show not found message when resourceId does not exist', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'resource-1',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Test Resource</p>',
},
],
},
],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'nonexistent-id' } }} />);
expect(screen.getByText('UI resource nonexistent-id not found')).toBeInTheDocument();
expect(screen.queryByTestId('ui-resource-renderer')).not.toBeInTheDocument();
});
it('should show not found when no ui_resources attachments', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'web_search',
web_search: { results: [] },
},
],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
});
it('should resolve resources by resourceId across conversation messages', () => {
mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any);
currentTestMessages = [
{
messageId: 'msg-previous',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'abc123',
uri: 'ui://test/resource-id',
mimeType: 'text/html',
text: '<p>Resource via ID</p>',
},
],
},
],
},
{
messageId: 'msg-current',
attachments: [],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'abc123' } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource-id');
});
});
describe('UI action handling', () => {
it('should handle UI actions with handleUIAction', async () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'resource-1',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Interactive Resource</p>',
},
],
},
],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
renderer.click();
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAskFn);
});
});
describe('edge cases', () => {
it('should handle empty messages array', () => {
currentTestMessages = [];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
});
it('should handle null messages data', () => {
currentTestMessages = [];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
});
it('should handle missing conversation', () => {
currentTestMessages = [];
mockUseMessagesConversation.mockReturnValue({
conversation: null,
conversationId: null,
} as any);
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
});
it('should handle multiple attachments of ui_resources type', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'resource-1',
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1</p>',
},
],
},
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'resource-2',
uri: 'ui://test/resource2',
mimeType: 'text/html',
text: '<p>Resource 2</p>',
},
{
resourceId: 'resource-3',
uri: 'ui://test/resource3',
mimeType: 'text/html',
text: '<p>Resource 3</p>',
},
],
},
],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-2' } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource2');
});
});
});

View file

@ -0,0 +1,263 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
// Mock dependencies
jest.mock('~/Providers');
jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
__esModule: true,
default: ({ uiResources }: any) => (
<div data-testid="ui-resource-carousel" data-resource-count={uiResources.length}>
{uiResources.map((resource: any, index: number) => (
<div key={index} data-testid={`resource-${index}`} data-resource-uri={resource.uri} />
))}
</div>
),
}));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
>;
describe('MCPUIResourceCarousel', () => {
// Store the current test's messages so getMessages can return them
let currentTestMessages: any[] = [];
beforeEach(() => {
jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv123' },
conversationId: 'conv123',
} as any);
mockUseMessagesOperations.mockReturnValue({
getMessages: () => currentTestMessages,
ask: jest.fn(),
regenerate: jest.fn(),
handleContinue: jest.fn(),
setMessages: jest.fn(),
} as any);
});
const renderWithRecoil = (ui: React.ReactNode) => render(<RecoilRoot>{ui}</RecoilRoot>);
describe('multiple resource fetching', () => {
it('should fetch resources by resourceIds across conversation messages', () => {
mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any);
currentTestMessages = [
{
messageId: 'msg-origin',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'id-1',
uri: 'ui://test/resource-id1',
mimeType: 'text/html',
text: '<p>Resource via ID 1</p>',
},
{
resourceId: 'id-2',
uri: 'ui://test/resource-id2',
mimeType: 'text/html',
text: '<p>Resource via ID 2</p>',
},
],
},
],
},
{
messageId: 'msg-current',
attachments: [],
},
];
renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: ['id-2', 'id-1'] } }} />,
);
const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '2');
expect(screen.getByTestId('resource-0')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource-id2',
);
expect(screen.getByTestId('resource-1')).toHaveAttribute(
'data-resource-uri',
'ui://test/resource-id1',
);
});
});
describe('error handling', () => {
it('should return null when no attachments', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: undefined,
},
];
const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: ['id1', 'id2'] } }} />,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument();
});
it('should return null when resources not found', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'existing-id',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Resource content</p>',
},
],
},
],
},
];
const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: ['non-existent-id'] } }} />,
);
expect(container.firstChild).toBeNull();
});
it('should return null when no ui_resources attachments', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'web_search',
web_search: { results: [] },
},
],
},
];
const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: ['id1', 'id2'] } }} />,
);
expect(container.firstChild).toBeNull();
});
});
describe('edge cases', () => {
it('should handle empty resourceIds array', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'test-id',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Resource content</p>',
},
],
},
],
},
];
const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: [] } }} />,
);
expect(container.firstChild).toBeNull();
});
it('should handle duplicate resource IDs', () => {
currentTestMessages = [
{
messageId: 'msg123',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'id-a',
uri: 'ui://test/resource-a',
mimeType: 'text/html',
text: '<p>Resource A content</p>',
},
{
resourceId: 'id-b',
uri: 'ui://test/resource-b',
mimeType: 'text/html',
text: '<p>Resource B content</p>',
},
],
},
],
},
];
renderWithRecoil(
<MCPUIResourceCarousel
node={{ properties: { resourceIds: ['id-a', 'id-a', 'id-b', 'id-b', 'id-a'] } }}
/>,
);
const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '5');
const resources = screen.getAllByTestId(/resource-\d/);
expect(resources).toHaveLength(5);
expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a');
expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a');
expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource-b');
expect(resources[3]).toHaveAttribute('data-resource-uri', 'ui://test/resource-b');
expect(resources[4]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a');
});
it('should handle null messages data', () => {
currentTestMessages = [];
const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: ['test-id'] } }} />,
);
expect(container.firstChild).toBeNull();
});
it('should handle missing conversation', () => {
mockUseMessagesConversation.mockReturnValue({
conversation: null,
conversationId: null,
} as any);
currentTestMessages = [];
const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIds: ['test-id'] } }} />,
);
expect(container.firstChild).toBeNull();
});
});
});

View file

@ -0,0 +1,301 @@
import { mcpUIResourcePlugin, UI_RESOURCE_MARKER } from '../plugin';
import type { Node } from 'unist';
import type { UIResourceNode } from '../types';
describe('mcpUIResourcePlugin', () => {
const createTextNode = (value: string): UIResourceNode => ({
type: 'text',
value,
});
const createTree = (nodes: UIResourceNode[]): Node =>
({
type: 'root',
children: nodes,
}) as Node;
const processTree = (tree: Node) => {
const plugin = mcpUIResourcePlugin();
plugin(tree);
return tree;
};
describe('single resource markers', () => {
it('should replace single UI resource marker with mcp-ui-resource node', () => {
const tree = createTree([createTextNode(`Here is a resource ${UI_RESOURCE_MARKER}{abc123}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Here is a resource ' });
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties).toMatchObject({
resourceId: 'abc123',
});
});
it('should handle multiple single resource markers', () => {
const tree = createTree([
createTextNode(`First ${UI_RESOURCE_MARKER}{id1} and second ${UI_RESOURCE_MARKER}{id2}`),
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(4);
expect(children[0]).toEqual({ type: 'text', value: 'First ' });
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties).toMatchObject({ resourceId: 'id1' });
expect(children[2]).toEqual({ type: 'text', value: ' and second ' });
expect(children[3].type).toBe('mcp-ui-resource');
expect(children[3].data.hProperties).toMatchObject({ resourceId: 'id2' });
});
it('should handle hex IDs', () => {
const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}{a3f2b8c1d4}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children[1].data.hProperties).toMatchObject({ resourceId: 'a3f2b8c1d4' });
});
});
describe('carousel markers', () => {
it('should replace carousel marker with mcp-ui-carousel node', () => {
const tree = createTree([createTextNode(`Carousel ${UI_RESOURCE_MARKER}{id1,id2,id3}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Carousel ' });
expect(children[1]).toEqual({
type: 'mcp-ui-carousel',
data: {
hName: 'mcp-ui-carousel',
hProperties: {
resourceIds: ['id1', 'id2', 'id3'],
},
},
});
});
it('should handle multiple IDs in carousel', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{alpha,beta,gamma}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children[0].data.hProperties.resourceIds).toEqual(['alpha', 'beta', 'gamma']);
});
});
describe('id-based markers', () => {
it('should replace single ID marker with mcp-ui-resource node', () => {
const tree = createTree([createTextNode(`Check this ${UI_RESOURCE_MARKER}{abc123}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Check this ' });
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties).toEqual({
resourceId: 'abc123',
});
});
it('should replace carousel ID marker with mcp-ui-carousel node', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{one,two,three}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({
type: 'mcp-ui-carousel',
data: {
hName: 'mcp-ui-carousel',
hProperties: {
resourceIds: ['one', 'two', 'three'],
},
},
});
});
it('should ignore empty IDs', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({ type: 'text', value: `${UI_RESOURCE_MARKER}{}` });
});
});
describe('mixed content', () => {
it('should handle text before and after markers', () => {
const tree = createTree([
createTextNode(
`Before ${UI_RESOURCE_MARKER}{id1} middle ${UI_RESOURCE_MARKER}{id2,id3} after`,
),
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(5);
expect(children[0].value).toBe('Before ');
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[2].value).toBe(' middle ');
expect(children[3].type).toBe('mcp-ui-carousel');
expect(children[4].value).toBe(' after');
});
it('should handle marker at start of text', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{id1} after`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0].type).toBe('mcp-ui-resource');
expect(children[1].value).toBe(' after');
});
it('should handle marker at end of text', () => {
const tree = createTree([createTextNode(`Before ${UI_RESOURCE_MARKER}{id1}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0].value).toBe('Before ');
expect(children[1].type).toBe('mcp-ui-resource');
});
it('should handle consecutive markers', () => {
const tree = createTree([
createTextNode(`${UI_RESOURCE_MARKER}{id1}${UI_RESOURCE_MARKER}{id2}`),
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0].type).toBe('mcp-ui-resource');
expect(children[0].data.hProperties).toEqual({ resourceId: 'id1' });
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties).toEqual({ resourceId: 'id2' });
});
});
describe('edge cases', () => {
it('should handle empty text nodes', () => {
const tree = createTree([createTextNode('')]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({ type: 'text', value: '' });
});
it('should handle text without markers', () => {
const tree = createTree([createTextNode('No markers here')]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({ type: 'text', value: 'No markers here' });
});
it('should handle non-text nodes', () => {
const tree = createTree([{ type: 'paragraph', children: [] }]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('paragraph');
});
it('should handle nested structures', () => {
const tree = {
type: 'root',
children: [
{
type: 'paragraph',
children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}{id1}`)],
},
],
} as Node;
processTree(tree);
const paragraph = (tree as any).children[0];
const textNodes = paragraph.children;
expect(textNodes).toHaveLength(2);
expect(textNodes[0].value).toBe('Text with ');
expect(textNodes[1].type).toBe('mcp-ui-resource');
});
it('should not process nodes without value property', () => {
const tree = createTree([
{
type: 'text',
// no value property
} as UIResourceNode,
]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
});
describe('pattern validation', () => {
it('should not match marker alone', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should not match marker without braces', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}abc`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should not match marker with leading comma', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{,id}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should not match marker without backslash', () => {
const tree = createTree([createTextNode('ui{id}')]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should handle valid hex ID patterns', () => {
const validPatterns = [
{ input: `${UI_RESOURCE_MARKER}{abc123}`, id: 'abc123' },
{ input: `${UI_RESOURCE_MARKER}{a3f2b8c1d4}`, id: 'a3f2b8c1d4' },
{ input: `${UI_RESOURCE_MARKER}{1234567890}`, id: '1234567890' },
{ input: `${UI_RESOURCE_MARKER}{abcdef0123}`, id: 'abcdef0123' },
{ input: `${UI_RESOURCE_MARKER}{deadbeef}`, id: 'deadbeef' },
{ input: `${UI_RESOURCE_MARKER}{a1b2c3}`, id: 'a1b2c3' },
];
validPatterns.forEach(({ input, id }) => {
const tree = createTree([createTextNode(input)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('mcp-ui-resource');
expect(children[0].data.hProperties).toEqual({ resourceId: id });
});
});
});
});

View file

@ -0,0 +1,4 @@
export { mcpUIResourcePlugin } from './plugin';
export { MCPUIResource } from './MCPUIResource';
export { MCPUIResourceCarousel } from './MCPUIResourceCarousel';
export * from './types';

View file

@ -0,0 +1,91 @@
import { visit } from 'unist-util-visit';
import type { Node } from 'unist';
import type { UIResourceNode } from './types';
export const UI_RESOURCE_MARKER = '\\ui';
// Pattern matches: \ui{id1} or \ui{id1,id2,id3} and captures everything between the braces
export const UI_RESOURCE_PATTERN = /\\ui\{([\w]+(?:,[\w]+)*)\}/g;
/**
* Process text nodes and replace UI resource markers with components
*/
function processTree(tree: Node) {
visit(tree, 'text', (node, index, parent) => {
const textNode = node as UIResourceNode;
const parentNode = parent as UIResourceNode;
if (typeof textNode.value !== 'string') return;
const originalValue = textNode.value;
const segments: Array<UIResourceNode> = [];
let currentPosition = 0;
UI_RESOURCE_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = UI_RESOURCE_PATTERN.exec(originalValue)) !== null) {
const matchIndex = match.index;
const matchText = match[0];
const idGroup = match[1];
const idValues = idGroup
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (matchIndex > currentPosition) {
const textBeforeMatch = originalValue.substring(currentPosition, matchIndex);
if (textBeforeMatch) {
segments.push({ type: 'text', value: textBeforeMatch });
}
}
if (idValues.length === 1) {
segments.push({
type: 'mcp-ui-resource',
data: {
hName: 'mcp-ui-resource',
hProperties: {
resourceId: idValues[0],
},
},
});
} else if (idValues.length > 1) {
segments.push({
type: 'mcp-ui-carousel',
data: {
hName: 'mcp-ui-carousel',
hProperties: {
resourceIds: idValues,
},
},
});
} else {
// Unable to parse marker; keep original text
segments.push({ type: 'text', value: matchText });
}
currentPosition = matchIndex + matchText.length;
}
if (currentPosition < originalValue.length) {
const remainingText = originalValue.substring(currentPosition);
if (remainingText) {
segments.push({ type: 'text', value: remainingText });
}
}
if (segments.length > 0 && index !== undefined) {
parentNode.children?.splice(index, 1, ...segments);
return index + segments.length;
}
});
}
/**
* Remark plugin for processing MCP UI resource markers
*/
export function mcpUIResourcePlugin() {
return (tree: Node) => {
processTree(tree);
};
}

View file

@ -0,0 +1,12 @@
export interface UIResourceNode {
type: string;
value?: string;
data?: {
hName: string;
hProperties: {
resourceId?: string;
resourceIds?: string[];
};
};
children?: UIResourceNode[];
}

View file

@ -0,0 +1,44 @@
import React, { useMemo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { MessagesViewContext } from '~/Providers/MessagesViewContext';
import type { MessagesViewContextValue } from '~/Providers/MessagesViewContext';
interface ShareMessagesProviderProps {
messages: TMessage[];
children: React.ReactNode;
}
/**
* Minimal MessagesViewContext provider for share view.
* Provides conversation data needed by message components.
* Uses the same MessagesViewContext as the main app for compatibility with existing hooks.
*
* Note: conversationId is set to undefined because share view is read-only and doesn't
* need to check Recoil state for in-flight messages during streaming.
*/
export function ShareMessagesProvider({ messages, children }: ShareMessagesProviderProps) {
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
conversation: { conversationId: 'shared-conversation' },
conversationId: undefined,
// These are required by the context but not used in share view
ask: () => Promise.resolve(),
regenerate: () => {},
handleContinue: () => {},
latestMessage: messages[messages.length - 1] ?? null,
isSubmitting: false,
isSubmittingFamily: false,
abortScroll: false,
setAbortScroll: () => {},
index: 0,
setLatestMessage: () => {},
getMessages: () => messages,
setMessages: () => {},
}),
[messages],
);
return (
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
);
}

View file

@ -21,6 +21,7 @@ import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { ShareContext } from '~/Providers';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import MessagesView from './MessagesView';
import Footer from '../Chat/Footer';
import { cn } from '~/utils';
@ -108,7 +109,9 @@ function SharedView() {
onLangChange={handleLangChange}
settingsLabel={localize('com_nav_settings')}
/>
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
<ShareMessagesProvider messages={data.messages}>
<MessagesView messagesTree={messagesTree} conversationId="shared-conversation" />
</ShareMessagesProvider>
</>
);
} else {