= 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;
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx
index 38b792ccae..bd47bfdda3 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx
@@ -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: '
Supported
',
+ },
+ ],
+ },
+ ];
+
+ render(
);
+
+ 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(
);
+
+ expect(UIResourceRenderer).not.toHaveBeenCalled();
+ expect(UIResourceCarousel).not.toHaveBeenCalled();
+ });
+
it('should handle no attachments', () => {
render(
);
@@ -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),
);
diff --git a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx
index 0f66cc65e4..0e27711e33 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx
@@ -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 }) =>
,
+ }),
+ { 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(
+
,
+ );
+
+ 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(
+
,
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
it('renders all UI resources', () => {
render(
);
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(
+
,
+ );
+ expect(container.querySelector('.hide-scrollbar')).not.toBeInTheDocument();
+
+ rerender(
);
+ 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(
);
const scrollContainer = container.querySelector('.hide-scrollbar');
diff --git a/client/src/components/MCPUIResource/MCPUIResource.tsx b/client/src/components/MCPUIResource/MCPUIResource.tsx
index d5fb0f868f..3aec3cba14 100644
--- a/client/src/components/MCPUIResource/MCPUIResource.tsx
+++ b/client/src/components/MCPUIResource/MCPUIResource.tsx
@@ -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 (
@@ -42,7 +46,6 @@ export function MCPUIResource(props: MCPUIResourceProps) {
onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
- sandboxPermissions: 'allow-popups',
}}
/>
diff --git a/client/src/components/MCPUIResource/Renderer.tsx b/client/src/components/MCPUIResource/Renderer.tsx
new file mode 100644
index 0000000000..f5bc460d19
--- /dev/null
+++ b/client/src/components/MCPUIResource/Renderer.tsx
@@ -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
;
+
+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 (
+
+ );
+}
diff --git a/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx b/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx
index c37b6d5d51..430043bccf 100644
--- a/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx
+++ b/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx
@@ -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(
+ ,
+ );
+
+ 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 = [
diff --git a/client/src/components/MCPUIResource/__tests__/Renderer.test.tsx b/client/src/components/MCPUIResource/__tests__/Renderer.test.tsx
new file mode 100644
index 0000000000..b04ced768b
--- /dev/null
+++ b/client/src/components/MCPUIResource/__tests__/Renderer.test.tsx
@@ -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 }) => (
+
+ )),
+}));
+
+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='
'",
+ };
+
+ const { container } = render();
+
+ 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: 'Malformed resource
',
+ };
+
+ const { container } = render();
+
+ 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: 'Safe iframe content
',
+ };
+
+ render(
+ ,
+ );
+
+ 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: 'Safe iframe content
',
+ };
+
+ render();
+
+ expect(mockLegacyRenderer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ resource: expect.objectContaining({ mimeType: 'text/html' }),
+ supportedContentTypes: ['rawHtml'],
+ }),
+ expect.any(Object),
+ );
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 8d65f15893..a5733d6282 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45238,6 +45238,16 @@
"name": "@librechat/data-schemas",
"version": "0.0.68",
"license": "MIT",
+ "dependencies": {
+ "mdast-util-directive": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.1",
+ "mdast-util-gfm": "^3.0.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-directive": "^3.0.1",
+ "micromark-extension-gfm": "^3.0.0",
+ "micromark-extension-llm-math": "^3.1.0",
+ "micromark-util-decode-string": "^2.0.0"
+ },
"devDependencies": {
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
diff --git a/packages/data-schemas/jest.config.mjs b/packages/data-schemas/jest.config.mjs
index 800143d679..739c2eecf9 100644
--- a/packages/data-schemas/jest.config.mjs
+++ b/packages/data-schemas/jest.config.mjs
@@ -2,6 +2,9 @@ export default {
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'],
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/misc/'],
+ transformIgnorePatterns: [
+ '/node_modules/(?!mdast-util-|micromark|decode-named-character-reference|devlop|longest-streak|unist-util-|zwitch|character-(?:entities|reference)|parse-entities|stringify-entities|is-(?:alphanumerical|alphabetical|decimal|hexadecimal)|ccount|markdown-table|escape-string-regexp)',
+ ],
coverageReporters: ['text', 'cobertura'],
testResultsProcessor: 'jest-junit',
moduleNameMapper: {
diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json
index ca6ee89244..2b0f23e80d 100644
--- a/packages/data-schemas/package.json
+++ b/packages/data-schemas/package.json
@@ -52,6 +52,16 @@
"url": "https://github.com/danny-avila/LibreChat/issues"
},
"homepage": "https://librechat.ai",
+ "dependencies": {
+ "mdast-util-directive": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.1",
+ "mdast-util-gfm": "^3.0.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-directive": "^3.0.1",
+ "micromark-extension-gfm": "^3.0.0",
+ "micromark-extension-llm-math": "^3.1.0",
+ "micromark-util-decode-string": "^2.0.0"
+ },
"devDependencies": {
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts
index a220116496..fbd1189445 100644
--- a/packages/data-schemas/src/methods/share.test.ts
+++ b/packages/data-schemas/src/methods/share.test.ts
@@ -1,7 +1,7 @@
import { nanoid } from 'nanoid';
import mongoose from 'mongoose';
-import { Constants } from 'librechat-data-provider';
import { MongoMemoryServer } from 'mongodb-memory-server';
+import { Constants, ContentTypes, Tools } from 'librechat-data-provider';
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
import type * as t from '~/types';
import { createShareMethods, anonymizeSharedContent, type ShareMethods } from './share';
@@ -521,6 +521,133 @@ describe('Share Methods', () => {
).toBe(result?.conversationId);
});
+ test('strips MCP-UI attachments from public shared messages', async () => {
+ const userId = new mongoose.Types.ObjectId().toString();
+ const conversationId = `conv_${nanoid()}`;
+ const shareId = `share_${nanoid()}`;
+
+ const message = await Message.create({
+ messageId: `msg_${nanoid()}`,
+ conversationId,
+ user: userId,
+ text: '\\ui{malicious}',
+ isCreatedByUser: false,
+ content: [
+ { type: ContentTypes.TEXT, text: 'Before \\ui{malicious} after' },
+ { type: ContentTypes.TEXT, text: '`\\ui{literal}`' },
+ {
+ type: ContentTypes.TEXT,
+ text: {
+ value: 'Object \\ui{malicious} value',
+ annotations: [{ type: 'citation' }],
+ },
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ subagent_content: [
+ { type: ContentTypes.TEXT, text: 'Nested \\ui{malicious} content' },
+ ],
+ },
+ },
+ ],
+ attachments: [
+ {
+ type: Tools.ui_resources,
+ [Tools.ui_resources]: [
+ {
+ resourceId: 'malicious',
+ mimeType: 'application/vnd.mcp-ui.remote-dom+javascript',
+ text: "root.innerHTML='
'",
+ },
+ ],
+ },
+ { type: Tools.web_search, [Tools.web_search]: { results: [] } },
+ ],
+ });
+
+ await SharedLink.create({
+ shareId,
+ conversationId,
+ user: userId,
+ messages: [message._id],
+ });
+
+ const result = await shareMethods.getSharedMessages(shareId);
+
+ expect(result?.messages[0].attachments).toHaveLength(1);
+ expect(result?.messages[0].attachments?.[0].type).toBe(Tools.web_search);
+ expect(result?.messages[0].text).toBe('');
+ expect(result?.messages[0].content).toEqual([
+ { type: ContentTypes.TEXT, text: 'Before after' },
+ { type: ContentTypes.TEXT, text: '`\\ui{literal}`' },
+ {
+ type: ContentTypes.TEXT,
+ text: { value: 'Object value', annotations: [{ type: 'citation' }] },
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested content' }],
+ },
+ },
+ ]);
+ });
+
+ test.each([true, null])(
+ 'matches text and content renderers for author flag %s in public shares',
+ async (authorFlag) => {
+ const userId = new mongoose.Types.ObjectId().toString();
+ const conversationId = `conv_${nanoid()}`;
+ const shareId = `share_${nanoid()}`;
+
+ const message = await Message.create({
+ messageId: `msg_${nanoid()}`,
+ conversationId,
+ user: userId,
+ text: 'Example: \\ui{literal}',
+ isCreatedByUser: authorFlag,
+ content: [
+ { type: ContentTypes.TEXT, text: 'Part: \\ui{literal}' },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ name: Constants.SUBAGENT,
+ output: 'Legacy \\ui{nested} output',
+ subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested \\ui{nested} text' }],
+ },
+ },
+ ],
+ attachments: [{ type: Tools.ui_resources, [Tools.ui_resources]: [] }],
+ });
+ await SharedLink.create({
+ shareId,
+ conversationId,
+ user: userId,
+ messages: [message._id],
+ });
+
+ const result = await shareMethods.getSharedMessages(shareId);
+
+ expect(result?.messages[0].text).toBe('Example: \\ui{literal}');
+ expect(result?.messages[0].content).toEqual([
+ {
+ type: ContentTypes.TEXT,
+ text: authorFlag === true ? 'Part: \\ui{literal}' : 'Part: ',
+ },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ name: Constants.SUBAGENT,
+ output: 'Legacy output',
+ subagent_content: [{ type: ContentTypes.TEXT, text: 'Nested text' }],
+ },
+ },
+ ]);
+ expect(result?.messages[0].attachments).toBeUndefined();
+ },
+ );
+
test('strips storage-internal fields while preserving shared render data', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
@@ -741,7 +868,7 @@ describe('Share Methods', () => {
expect(share?.fileSnapshots?.map((snapshot) => snapshot.file_id)).toContain('steer-file-2');
});
- test('leaves non-steer content untouched (same array reference when no steer part)', () => {
+ test('leaves safe non-steer content untouched (same array reference)', () => {
const plainContent = [
{ type: 'text', text: 'no steers here' },
{ type: 'tool_call', tool_call: { id: 'call_1' } },
diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts
index 956cb9c087..88356114a6 100644
--- a/packages/data-schemas/src/methods/share.ts
+++ b/packages/data-schemas/src/methods/share.ts
@@ -1,9 +1,13 @@
import { nanoid } from 'nanoid';
import { Types } from 'mongoose';
-import { Constants, ContentTypes, FileSources } from 'librechat-data-provider';
+import { Constants, ContentTypes, FileSources, Tools } from 'librechat-data-provider';
import type { FilterQuery, Model } from 'mongoose';
import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili';
import type * as t from '~/types';
+import {
+ sanitizeUIResourceContent,
+ stripMessageUIResourceMarkers,
+} from '~/utils/stripUIResourceMarkers';
import { activeExpirationFilter } from '~/utils/retention';
import { isValidObjectIdString } from '~/utils/objectId';
import logger from '~/config/winston';
@@ -118,6 +122,13 @@ function sanitizeSharedFiles(files: unknown): t.SharedFile[] | undefined {
return sanitized.length > 0 ? sanitized : undefined;
}
+function sanitizeSharedAttachments(attachments: unknown): t.SharedFile[] | undefined {
+ const sanitized = sanitizeSharedFiles(attachments)?.filter(
+ (attachment) => attachment.type !== Tools.ui_resources,
+ );
+ return sanitized && sanitized.length > 0 ? sanitized : undefined;
+}
+
/**
* Sources backed by a durable stored object that the share-scoped routes can
* stream with only `storageKey`/`filepath` + the request. Sources requiring
@@ -379,6 +390,7 @@ export function anonymizeSharedContent(
shareId: string;
snapshotIds: Set;
includeFiles: boolean;
+ sanitizeUIResourceMarkers?: boolean;
},
): unknown[] | undefined {
if (!Array.isArray(content)) {
@@ -386,8 +398,13 @@ export function anonymizeSharedContent(
}
let result: unknown[] | null = null;
+ const sanitized = sanitizeUIResourceContent(content, params.sanitizeUIResourceMarkers === true);
+ if (sanitized !== content) {
+ result = sanitized as unknown[];
+ }
+
for (let i = 0; i < content.length; i++) {
- const part = content[i];
+ const part = result?.[i] ?? content[i];
if (!isSteerPartWithFiles(part)) {
continue;
}
@@ -405,9 +422,7 @@ export function anonymizeSharedContent(
),
)
: undefined;
- if (result == null) {
- result = [...content];
- }
+ result ??= [...content];
result[i] = files ? { ...rest, files } : rest;
}
return result ?? content;
@@ -456,7 +471,7 @@ function anonymizeMessages(
// When files are not shared for this link, omit files/attachments entirely so
// viewers can't load them through the owner's original (e.g. static) paths.
const attachments = includeFiles
- ? sanitizeSharedFiles(message.attachments)?.map((attachment) =>
+ ? sanitizeSharedAttachments(message.attachments)?.map((attachment) =>
applyShareFileRoute(
{
...attachment,
@@ -492,13 +507,17 @@ function anonymizeMessages(
anonymizeMessageId(message.parentMessageId || ''),
conversationId: newConvoId,
sender: message.sender,
- text: message.text,
+ text:
+ message.isCreatedByUser === false
+ ? stripMessageUIResourceMarkers(message.text, message.error)
+ : message.text,
content: anonymizeSharedContent(message.content, {
newConvoId,
newMessageId,
shareId,
snapshotIds,
includeFiles,
+ sanitizeUIResourceMarkers: message.isCreatedByUser !== true,
}),
...(message.iconURL && { iconURL: message.iconURL }),
...(model && { model }),
diff --git a/packages/data-schemas/src/utils/index.ts b/packages/data-schemas/src/utils/index.ts
index 1cdf337be0..5ef6d70bb1 100644
--- a/packages/data-schemas/src/utils/index.ts
+++ b/packages/data-schemas/src/utils/index.ts
@@ -6,3 +6,4 @@ export { tenantSafeBulkWrite } from './tenantBulkWrite';
export * from './transactions';
export * from './objectId';
export * from './yaml';
+export * from './stripUIResourceMarkers';
diff --git a/packages/data-schemas/src/utils/stripUIResourceMarkers.spec.ts b/packages/data-schemas/src/utils/stripUIResourceMarkers.spec.ts
new file mode 100644
index 0000000000..44e198beaf
--- /dev/null
+++ b/packages/data-schemas/src/utils/stripUIResourceMarkers.spec.ts
@@ -0,0 +1,255 @@
+import { Constants, ContentTypes } from 'librechat-data-provider';
+import {
+ sanitizeUIResourceContent,
+ stripMessageUIResourceMarkers,
+ stripUIResourceMarkers,
+} from './stripUIResourceMarkers';
+
+describe('stripUIResourceMarkers', () => {
+ it('removes markers from renderable text while preserving non-text Markdown nodes', () => {
+ const markdown = [
+ 'Before \\ui{rendered} after',
+ '`\\ui{inline}`',
+ '```text',
+ '\\ui{fenced}',
+ '```',
+ '\\ui{html}
',
+ '$$\\ui{inline-math}$$',
+ '$$',
+ '\\ui{display-math}',
+ '$$',
+ ].join('\n');
+
+ expect(stripUIResourceMarkers(markdown)).toBe(markdown.replace('\\ui{rendered}', ''));
+ });
+
+ it('preserves markers inside inline and display math nodes', () => {
+ const markdown = ['$$\\ui{inline-math}$$', '', '$$', '\\ui{display-math}', '$$'].join('\n');
+ expect(stripUIResourceMarkers(markdown)).toBe(markdown);
+ expect(stripUIResourceMarkers('$\\ui{rendered}$')).toBe('$$');
+ });
+
+ it('preserves directive attributes that the renderer does not visit as text', () => {
+ const markdown = '::artifact{title="\\ui{example}"}';
+ expect(stripUIResourceMarkers(markdown)).toBe(markdown);
+ });
+
+ it('preserves text directive labels replaced before MCP marker rendering', () => {
+ const markdown = ':note[\\ui{directive}] outside \\ui{outside}';
+ expect(stripUIResourceMarkers(markdown)).toBe(':note[\\ui{directive}] outside ');
+ });
+
+ it('removes markers after Markdown escapes and character references are decoded', () => {
+ const markdown = [
+ 'Encoded \ui{backslash}',
+ 'Encoded \\ui{braces}',
+ 'Escaped \\ui\\{braces\\}',
+ ].join('\n');
+
+ expect(stripUIResourceMarkers(markdown)).toBe(['Encoded ', 'Encoded ', 'Escaped '].join('\n'));
+ });
+
+ it('removes markers formed by citation cleanup before MCP marker rendering', () => {
+ const markdown = [
+ 'Actual \\u\ue206i{actual}',
+ 'Literal \\u\\ue206i{literal}',
+ 'Entity \\ui{entity}',
+ 'Mixed \\u\ue206i{mixed}',
+ ].join('\n');
+ expect(stripUIResourceMarkers(markdown)).toBe(
+ ['Actual ', 'Literal ', 'Entity ', 'Mixed '].join('\n'),
+ );
+ });
+
+ it('preserves markers inside composite citations consumed before MCP marker rendering', () => {
+ const markdown = '\\ue200\\ue202turn0search0 \\ui{citation}\\ue201 outside \\ui{outside}';
+ expect(stripUIResourceMarkers(markdown)).toBe(
+ '\\ue200\\ue202turn0search0 \\ui{citation}\\ue201 outside ',
+ );
+ expect(stripUIResourceMarkers('\\ue200 invalid \\ui{rendered}\\ue201')).toBe(
+ '\\ue200 invalid \\ue201',
+ );
+ expect(stripUIResourceMarkers('\\ue200\n\\ue202turn0search0 \\ui{rendered}\\ue201')).toBe(
+ '\\ue200\n\\ue202turn0search0 \\ue201',
+ );
+ });
+
+ it('preserves highlighted citation text-node boundaries before MCP marker rendering', () => {
+ const acrossBoundary = '\\u\\ue203i{id}\\ue204';
+ expect(stripUIResourceMarkers(acrossBoundary)).toBe(acrossBoundary);
+ expect(stripUIResourceMarkers('\\ue203\\ui{inside}\\ue204')).toBe('\\ue203\\ue204');
+ expect(
+ stripUIResourceMarkers('\\ue203 text \\ue200\\ue202turn0search0 \\ui{nested}\\ue201\\ue204'),
+ ).toBe('\\ue203 text \\ue200\\ue202turn0search0 \\ue201\\ue204');
+ });
+
+ it('uses the earliest citation match when highlight and composite ranges cross', () => {
+ const markdown = '\\ue203 text \\ue200\\ue202turn0search0 \\ui{id}\\ue204 tail \\ue201';
+ expect(stripUIResourceMarkers(markdown)).toBe(
+ '\\ue203 text \\ue200\\ue202turn0search0 \\ue204 tail \\ue201',
+ );
+ });
+
+ it('preserves text boundaries around invalid composite citations', () => {
+ const markdown = '\\u\\ue200\\ue201i{id}';
+ expect(stripUIResourceMarkers(markdown)).toBe(markdown);
+ });
+
+ it('handles many unmatched citation openers in linear time', () => {
+ const markdown = `${'\\ue203'.repeat(16_000)}\\ui{rendered}`;
+ expect(stripUIResourceMarkers(markdown)).toBe('\\ue203'.repeat(16_000));
+ });
+
+ it('preserves markers in message text that bypasses Markdown rendering', () => {
+ expect(stripMessageUIResourceMarkers('Error \\ui{literal}', true)).toBe('Error \\ui{literal}');
+ expect(
+ stripMessageUIResourceMarkers(':::thinking Hidden \\ui{hidden} ::: Visible \\ui{visible}'),
+ ).toBe(':::thinking Hidden \\ui{hidden} ::: Visible ');
+ expect(stripMessageUIResourceMarkers('\\u:::thinking Hidden :::i{formed}')).toBe(
+ ':::thinking Hidden :::',
+ );
+ expect(stripMessageUIResourceMarkers(':::thinking hidden ::: \\ui{id}')).toBe(
+ ':::thinking hidden ::: ',
+ );
+ });
+
+ it('handles paragraph continuations and container code according to CommonMark', () => {
+ const markdown = [
+ 'intro',
+ ' \\ui{paragraph}',
+ '',
+ ' \\ui{indented-code}',
+ '> ```',
+ '> \\ui{quote-code}',
+ 'outside \\ui{outside}',
+ ].join('\n');
+
+ expect(stripUIResourceMarkers(markdown)).toBe(
+ markdown.replace('\\ui{paragraph}', '').replace('\\ui{outside}', ''),
+ );
+ });
+
+ it('maps markers across CRLF paragraph continuations', () => {
+ const markdown = 'intro\r\n \\ui{paragraph}\r\nnext \\ui{next}';
+ expect(stripUIResourceMarkers(markdown)).toBe('intro\r\n \r\nnext ');
+ });
+
+ it('maps markers across blockquote paragraph prefixes', () => {
+ const markdown = '> intro\n> \\ui{paragraph}\n>\n> \\ui{code}';
+ expect(stripUIResourceMarkers(markdown)).toBe(markdown.replace('\\ui{paragraph}', ''));
+ });
+
+ it('handles escaped blockquote content and CommonMark replacement characters', () => {
+ expect(stripUIResourceMarkers('> intro\n> \\> \\ui{paragraph}')).toBe('> intro\n> \\> ');
+ expect(stripUIResourceMarkers('prefix\0 \\ui{paragraph}')).toBe('prefix\0 ');
+ });
+
+ it('handles long literal text without per-character source-span objects', () => {
+ const markdown = `\\not-a-marker ${'a'.repeat(100_000)} & plain text`;
+ expect(stripUIResourceMarkers(markdown)).toBe(markdown);
+ });
+
+ it('maps many encoded markers with a monotonic source-segment cursor', () => {
+ const markdown = Array.from({ length: 1_000 }, (_, index) => `\ui{id${index}}`).join(' ');
+ expect(stripUIResourceMarkers(markdown)).toBe(' '.repeat(999));
+ });
+
+ it('walks deeply nested Markdown without recursive traversal', () => {
+ const prefix = '> '.repeat(500);
+ expect(stripUIResourceMarkers(`${prefix}\\ui{deep}`)).toBe(prefix);
+ });
+
+ it('recursively sanitizes TextData and subagent content while preserving annotations', () => {
+ const content = [
+ {
+ type: 'tool_call',
+ tool_call: {
+ subagent_content: [
+ {
+ type: ContentTypes.TEXT,
+ text: {
+ value: 'Before \\ui{nested} after',
+ annotations: [{ type: 'citation' }],
+ },
+ },
+ ],
+ },
+ },
+ ];
+
+ expect(sanitizeUIResourceContent(content)).toEqual([
+ {
+ type: 'tool_call',
+ tool_call: {
+ subagent_content: [
+ {
+ type: ContentTypes.TEXT,
+ text: { value: 'Before after', annotations: [{ type: 'citation' }] },
+ },
+ ],
+ },
+ },
+ ]);
+ });
+
+ it('sanitizes deeply nested subagent content without recursive traversal', () => {
+ const depth = 10_000;
+ let nestedContent: unknown[] = [{ type: ContentTypes.TEXT, text: 'Deep \\ui{deep} content' }];
+ for (let i = 0; i < depth; i++) {
+ nestedContent = [
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { subagent_content: nestedContent },
+ },
+ ];
+ }
+
+ let cursor = sanitizeUIResourceContent(nestedContent) as unknown[];
+ for (let i = 0; i < depth; i++) {
+ const part = cursor[0] as { tool_call: { subagent_content: unknown[] } };
+ cursor = part.tool_call.subagent_content;
+ }
+ expect(cursor).toEqual([{ type: ContentTypes.TEXT, text: 'Deep content' }]);
+ });
+
+ it('preserves top-level user text while sanitizing assistant-rendered subagent fields', () => {
+ const content = [
+ { type: ContentTypes.TEXT, text: 'User example \\ui{literal}' },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ name: Constants.SUBAGENT,
+ output: 'Legacy \\ui{legacy} output',
+ subagent_content: [
+ { type: ContentTypes.TEXT, text: 'Nested \\ui{nested} content' },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ name: Constants.SUBAGENT,
+ output: 'Deep \\ui{deep} output',
+ },
+ },
+ ],
+ },
+ },
+ ];
+
+ expect(sanitizeUIResourceContent(content, false)).toEqual([
+ { type: ContentTypes.TEXT, text: 'User example \\ui{literal}' },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ name: Constants.SUBAGENT,
+ output: 'Legacy output',
+ subagent_content: [
+ { type: ContentTypes.TEXT, text: 'Nested content' },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: { name: Constants.SUBAGENT, output: 'Deep output' },
+ },
+ ],
+ },
+ },
+ ]);
+ });
+});
diff --git a/packages/data-schemas/src/utils/stripUIResourceMarkers.ts b/packages/data-schemas/src/utils/stripUIResourceMarkers.ts
new file mode 100644
index 0000000000..4c36e099df
--- /dev/null
+++ b/packages/data-schemas/src/utils/stripUIResourceMarkers.ts
@@ -0,0 +1,599 @@
+import { gfm } from 'micromark-extension-gfm';
+import { gfmFromMarkdown } from 'mdast-util-gfm';
+import { mathFromMarkdown } from 'mdast-util-math';
+import { math } from 'micromark-extension-llm-math';
+import { fromMarkdown } from 'mdast-util-from-markdown';
+import { directive } from 'micromark-extension-directive';
+import { decodeString } from 'micromark-util-decode-string';
+import { directiveFromMarkdown } from 'mdast-util-directive';
+import { Constants, ContentTypes } from 'librechat-data-provider';
+
+const UI_RESOURCE_PATTERN = /\\ui\{[\w]+(?:,[\w]+)*\}/g;
+const CITATION_CLEANUP = /\\ue20[0-46]|[\ue200-\ue204\ue206]/g;
+const STANDALONE_SUFFIX = /turn(\d+)(search|image|news|video|ref|file)(\d+)/y;
+const MARKDOWN_ESCAPE_OR_REFERENCE = /\\(.)|&(#(?:\d{1,7}|[xX][\dA-Fa-f]{1,6})|[\dA-Za-z]{1,31});/g;
+const TEXT_BOUNDARY = '\0';
+
+type MarkdownNode = {
+ type: string;
+ position?: { start: { offset?: number }; end: { offset?: number } };
+ children?: MarkdownNode[];
+};
+
+type SourceSegment = {
+ decodedStart: number;
+ decodedEnd: number;
+ sourceStart: number;
+ sourceEnd: number;
+ literal: boolean;
+};
+
+type CitationToken = {
+ code: number;
+ start: number;
+ end: number;
+ line: number;
+ literal: boolean;
+};
+
+type CitationMatch = { start: number; end: number };
+
+type CitationOutputSegment =
+ | { kind: 'text'; intervals: Array<[number, number]> }
+ | { kind: 'nontext' };
+
+function collectCitationTokens(value: string) {
+ const tokens: CitationToken[] = [];
+ const allStandalone: CitationMatch[] = [];
+ const standalone: CitationMatch[] = [];
+ let lastCompositeOpen = -1;
+ let lastCompositeClose = -1;
+ let line = 0;
+
+ for (let index = 0; index < value.length; ) {
+ let code = -1;
+ let end = index + 1;
+ let literal = false;
+ const characterCode = value.charCodeAt(index);
+ if (characterCode >= 0xe200 && characterCode <= 0xe206 && characterCode !== 0xe205) {
+ code = characterCode - 0xe200;
+ } else if (
+ value.charCodeAt(index) === 92 &&
+ value.startsWith('ue20', index + 1) &&
+ index + 5 < value.length
+ ) {
+ const digit = value.charCodeAt(index + 5) - 48;
+ if (digit >= 0 && digit <= 6 && digit !== 5) {
+ code = digit;
+ end = index + 6;
+ literal = true;
+ }
+ }
+
+ if (code < 0) {
+ if (
+ characterCode === 10 ||
+ characterCode === 13 ||
+ characterCode === 0x2028 ||
+ characterCode === 0x2029
+ ) {
+ line++;
+ }
+ index++;
+ continue;
+ }
+
+ const token = { code, start: index, end, line, literal };
+ tokens.push(token);
+ if (code === 2) {
+ STANDALONE_SUFFIX.lastIndex = end;
+ const suffix = STANDALONE_SUFFIX.exec(value);
+ if (suffix) {
+ const citation = { start: index, end: STANDALONE_SUFFIX.lastIndex };
+ allStandalone.push(citation);
+ if (lastCompositeOpen === -1 || lastCompositeClose > lastCompositeOpen) {
+ standalone.push(citation);
+ }
+ }
+ } else if (code === 0) {
+ lastCompositeOpen = index;
+ } else if (code === 1) {
+ lastCompositeClose = index;
+ }
+ index = end;
+ }
+
+ return { tokens, allStandalone, standalone };
+}
+
+function pairCitationRanges(tokens: CitationToken[], openCode: number, closeCode: number) {
+ const openers = tokens.filter((token) => token.code === openCode);
+ const closers = tokens.filter((token) => token.code === closeCode);
+ const ranges: CitationMatch[] = [];
+ let closeIndex = 0;
+ for (const opener of openers) {
+ while (
+ closers[closeIndex] &&
+ (closers[closeIndex].line < opener.line ||
+ (closers[closeIndex].line === opener.line && closers[closeIndex].start < opener.end))
+ ) {
+ closeIndex++;
+ }
+ const closer = closers[closeIndex];
+ if (closer?.line === opener.line) {
+ ranges.push({ start: opener.start, end: closer.end });
+ }
+ }
+ return ranges;
+}
+
+/** Model the citation plugin's emitted text nodes without its unbounded wildcard regexes. */
+function applyCitationTextBoundaries(value: string, sourceSegments: SourceSegment[]) {
+ const { tokens, allStandalone, standalone } = collectCitationTokens(value);
+ const highlighted = pairCitationRanges(tokens, 3, 4);
+ const composite = pairCitationRanges(tokens, 0, 1);
+ const output: CitationOutputSegment[] = [];
+ let tokenIndex = 0;
+
+ const addText = (start: number, end: number, highlightedText = false) => {
+ const intervals: Array<[number, number]> = [];
+ let cursor = start;
+ while (tokens[tokenIndex]?.end <= start) {
+ tokenIndex++;
+ }
+ while (tokens[tokenIndex]?.start < end) {
+ const token = tokens[tokenIndex++];
+ const remove = highlightedText
+ ? token.literal && (token.code === 3 || token.code === 4)
+ : true;
+ if (remove) {
+ if (cursor < token.start) {
+ intervals.push([cursor, token.start]);
+ }
+ cursor = token.end;
+ }
+ }
+ if (cursor < end) {
+ intervals.push([cursor, end]);
+ }
+ if (intervals.length > 0 || highlightedText) {
+ output.push({ kind: 'text', intervals });
+ }
+ };
+
+ let highlightedIndex = 0;
+ let compositeIndex = 0;
+ let standaloneIndex = 0;
+ let compositeCitationIndex = 0;
+ let position = 0;
+ while (position < value.length) {
+ while (highlighted[highlightedIndex]?.start < position) {
+ highlightedIndex++;
+ }
+ while (composite[compositeIndex]?.start < position) {
+ compositeIndex++;
+ }
+ while (standalone[standaloneIndex]?.start < position) {
+ standaloneIndex++;
+ }
+
+ const highlightedMatch = highlighted[highlightedIndex];
+ const compositeMatch = composite[compositeIndex];
+ const standaloneMatch = standalone[standaloneIndex];
+ let type: 'highlighted' | 'composite' | 'standalone' | undefined;
+ let next = highlightedMatch;
+ if (next) {
+ type = 'highlighted';
+ }
+ if (compositeMatch && (!next || compositeMatch.start < next.start)) {
+ type = 'composite';
+ next = compositeMatch;
+ }
+ if (standaloneMatch && (!next || standaloneMatch.start < next.start)) {
+ type = 'standalone';
+ next = standaloneMatch;
+ }
+ if (!next || !type) {
+ addText(position, value.length);
+ break;
+ }
+
+ if (next.start > position) {
+ addText(position, next.start);
+ }
+ if (type === 'highlighted') {
+ addText(next.start, next.end, true);
+ highlightedIndex++;
+ } else if (type === 'standalone') {
+ output.push({ kind: 'nontext' });
+ standaloneIndex++;
+ } else {
+ while (allStandalone[compositeCitationIndex]?.start < next.start) {
+ compositeCitationIndex++;
+ }
+ const citation = allStandalone[compositeCitationIndex];
+ if (citation && citation.end <= next.end) {
+ output.push({ kind: 'nontext' });
+ }
+ compositeIndex++;
+ }
+ position = next.end;
+ }
+
+ if (output.length === 0) {
+ tokenIndex = 0;
+ addText(0, value.length);
+ }
+
+ let transformedValue = '';
+ const transformedSegments: SourceSegment[] = [];
+ let sourceSegmentIndex = 0;
+ const appendInterval = ([start, end]: [number, number]) => {
+ while (sourceSegments[sourceSegmentIndex]?.decodedEnd <= start) {
+ sourceSegmentIndex++;
+ }
+ let index = sourceSegmentIndex;
+ while (sourceSegments[index]?.decodedStart < end) {
+ const segment = sourceSegments[index];
+ const sliceStart = Math.max(start, segment.decodedStart);
+ const sliceEnd = Math.min(end, segment.decodedEnd);
+ if (sliceStart < sliceEnd) {
+ const decodedStart = transformedValue.length;
+ transformedValue += value.slice(sliceStart, sliceEnd);
+ const sourceStart = segment.literal
+ ? segment.sourceStart + sliceStart - segment.decodedStart
+ : segment.sourceStart;
+ const sourceEnd = segment.literal
+ ? segment.sourceStart + sliceEnd - segment.decodedStart
+ : segment.sourceEnd;
+ const previous = transformedSegments[transformedSegments.length - 1];
+ if (
+ segment.literal &&
+ previous?.literal &&
+ previous.decodedEnd === decodedStart &&
+ previous.sourceEnd === sourceStart
+ ) {
+ previous.decodedEnd = transformedValue.length;
+ previous.sourceEnd = sourceEnd;
+ } else {
+ transformedSegments.push({
+ decodedStart,
+ decodedEnd: transformedValue.length,
+ sourceStart,
+ sourceEnd,
+ literal: segment.literal,
+ });
+ }
+ }
+ index++;
+ }
+ sourceSegmentIndex = Math.max(sourceSegmentIndex, index - 1);
+ };
+
+ output.forEach((segment, index) => {
+ if (index > 0) {
+ transformedValue += TEXT_BOUNDARY;
+ }
+ if (segment.kind === 'text') {
+ segment.intervals.forEach(appendInterval);
+ }
+ });
+ return { value: transformedValue, segments: transformedSegments };
+}
+
+function decodeTextWithSourceSpans(source: string) {
+ let value = '';
+ const segments: SourceSegment[] = [];
+ let cursor = 0;
+ MARKDOWN_ESCAPE_OR_REFERENCE.lastIndex = 0;
+
+ const appendLiteral = (start: number, end: number) => {
+ if (start === end) {
+ return;
+ }
+ const decodedStart = value.length;
+ value += source.slice(start, end);
+ const previous = segments[segments.length - 1];
+ if (previous?.literal && previous.decodedEnd === decodedStart && previous.sourceEnd === start) {
+ previous.decodedEnd = value.length;
+ previous.sourceEnd = end;
+ return;
+ }
+ segments.push({
+ decodedStart,
+ decodedEnd: value.length,
+ sourceStart: start,
+ sourceEnd: end,
+ literal: true,
+ });
+ };
+
+ let match: RegExpExecArray | null;
+ while ((match = MARKDOWN_ESCAPE_OR_REFERENCE.exec(source)) != null) {
+ appendLiteral(cursor, match.index);
+ const decoded = decodeString(match[0]);
+ if (decoded === match[0]) {
+ appendLiteral(match.index, MARKDOWN_ESCAPE_OR_REFERENCE.lastIndex);
+ } else {
+ const decodedStart = value.length;
+ value += decoded;
+ segments.push({
+ decodedStart,
+ decodedEnd: value.length,
+ sourceStart: match.index,
+ sourceEnd: MARKDOWN_ESCAPE_OR_REFERENCE.lastIndex,
+ literal: false,
+ });
+ }
+ cursor = MARKDOWN_ESCAPE_OR_REFERENCE.lastIndex;
+ }
+ appendLiteral(cursor, source.length);
+ return applyCitationTextBoundaries(value, segments);
+}
+
+function mapDecodedRange(
+ segments: SourceSegment[],
+ decodedStart: number,
+ decodedEnd: number,
+ fromIndex: number,
+): { range: [number, number]; segmentIndex: number } | null {
+ let firstIndex = fromIndex;
+ while (segments[firstIndex]?.decodedEnd <= decodedStart) {
+ firstIndex++;
+ }
+ const first = segments[firstIndex];
+ if (!first || decodedStart < first.decodedStart) {
+ return null;
+ }
+
+ let lastIndex = firstIndex;
+ while (segments[lastIndex]?.decodedEnd < decodedEnd) {
+ lastIndex++;
+ }
+ const last = segments[lastIndex];
+ if (!last || decodedEnd <= last.decodedStart) {
+ return null;
+ }
+
+ const sourceStart = first.literal
+ ? first.sourceStart + decodedStart - first.decodedStart
+ : first.sourceStart;
+ const sourceEnd = last.literal
+ ? last.sourceStart + decodedEnd - last.decodedStart
+ : last.sourceEnd;
+ return { range: [sourceStart, sourceEnd], segmentIndex: lastIndex };
+}
+
+function collectMarkerRanges(root: MarkdownNode, source: string, ranges: Array<[number, number]>) {
+ const stack = [root];
+ while (stack.length > 0) {
+ const node = stack.pop() as MarkdownNode;
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ if (node.type === 'text' && start != null && end != null) {
+ const decoded = decodeTextWithSourceSpans(source.slice(start, end));
+ UI_RESOURCE_PATTERN.lastIndex = 0;
+ let segmentCursor = 0;
+ let match: RegExpExecArray | null;
+ while ((match = UI_RESOURCE_PATTERN.exec(decoded.value)) != null) {
+ const mapped = mapDecodedRange(
+ decoded.segments,
+ match.index,
+ match.index + match[0].length,
+ segmentCursor,
+ );
+ if (mapped) {
+ segmentCursor = mapped.segmentIndex;
+ ranges.push([start + mapped.range[0], start + mapped.range[1]]);
+ }
+ }
+ }
+ if (node.type === 'textDirective') {
+ continue;
+ }
+ if (node.children) {
+ for (let i = node.children.length - 1; i >= 0; i--) {
+ stack.push(node.children[i]);
+ }
+ }
+ }
+}
+
+function findUIResourceMarkerRanges(text: string): Array<[number, number]> {
+ if (
+ (!text.includes('\\') && !text.includes('&')) ||
+ !decodeString(text).replace(CITATION_CLEANUP, '').includes('\\ui{')
+ ) {
+ return [];
+ }
+ const ranges: Array<[number, number]> = [];
+ collectMarkerRanges(
+ fromMarkdown(text, {
+ extensions: [gfm(), directive(), math({ singleDollarTextMath: false })],
+ mdastExtensions: [gfmFromMarkdown(), directiveFromMarkdown(), mathFromMarkdown()],
+ }) as unknown as MarkdownNode,
+ text,
+ ranges,
+ );
+ return ranges;
+}
+
+function removeSourceRanges(text: string, ranges: Array<[number, number]>) {
+ let result = '';
+ let cursor = 0;
+ for (const [start, end] of ranges) {
+ result += text.slice(cursor, start);
+ cursor = end;
+ }
+ return result + text.slice(cursor);
+}
+
+/** Remove MCP-UI markers only from Markdown text nodes visited by the renderer plugin. */
+export function stripUIResourceMarkers(text: string): string;
+export function stripUIResourceMarkers(text: undefined): undefined;
+export function stripUIResourceMarkers(text: string | undefined): string | undefined;
+export function stripUIResourceMarkers(text: string | undefined): string | undefined {
+ if (text == null) {
+ return text;
+ }
+ const ranges = findUIResourceMarkerRanges(text);
+ return ranges.length === 0 ? text : removeSourceRanges(text, ranges);
+}
+
+/** Sanitize only the portion of a legacy message that the client renders as Markdown. */
+export function stripMessageUIResourceMarkers(
+ text: string | undefined,
+ error?: unknown,
+): string | undefined {
+ if (text == null || error) {
+ return text;
+ }
+ const thinkingMatch = /:::thinking[\s\S]*?:::/.exec(text);
+ if (!thinkingMatch) {
+ return stripUIResourceMarkers(text);
+ }
+ const start = thinkingMatch.index;
+ const end = start + thinkingMatch[0].length;
+ const regularContent = text.slice(0, start) + text.slice(end);
+ const renderedStart = regularContent.length - regularContent.trimStart().length;
+ const renderedEnd = regularContent.trimEnd().length;
+ const regularRanges = findUIResourceMarkerRanges(
+ regularContent.slice(renderedStart, renderedEnd),
+ );
+ if (regularRanges.length === 0) {
+ return text;
+ }
+ const sourceRanges: Array<[number, number]> = [];
+ const blockLength = end - start;
+ for (const renderedRange of regularRanges) {
+ const rangeStart = renderedRange[0] + renderedStart;
+ const rangeEnd = renderedRange[1] + renderedStart;
+ if (rangeEnd <= start) {
+ sourceRanges.push([rangeStart, rangeEnd]);
+ } else if (rangeStart >= start) {
+ sourceRanges.push([rangeStart + blockLength, rangeEnd + blockLength]);
+ } else {
+ sourceRanges.push([rangeStart, start], [end, rangeEnd + blockLength]);
+ }
+ }
+ return removeSourceRanges(text, sourceRanges);
+}
+
+function sanitizeTextPart(part: Record): unknown {
+ if (typeof part.text === 'string') {
+ const text = stripUIResourceMarkers(part.text);
+ return text === part.text ? part : { ...part, text };
+ }
+ if (part.text != null && typeof part.text === 'object') {
+ const textData = part.text as Record;
+ if (typeof textData.value === 'string') {
+ const value = stripUIResourceMarkers(textData.value);
+ return value === textData.value ? part : { ...part, text: { ...textData, value } };
+ }
+ }
+ return part;
+}
+
+type ContentFrame = {
+ content: unknown[];
+ sanitizeTextParts: boolean;
+ index: number;
+ result: unknown[] | null;
+ onComplete: (content: unknown[]) => void;
+};
+
+function updateFramePart(
+ frame: ContentFrame,
+ index: number,
+ originalPart: unknown,
+ sanitizedPart: unknown,
+) {
+ if (sanitizedPart === originalPart) {
+ return;
+ }
+ frame.result ??= [...frame.content];
+ frame.result[index] = sanitizedPart;
+}
+
+/** Sanitize assistant text parts, including arbitrarily nested persisted subagent content. */
+export function sanitizeUIResourceContent(content: unknown, sanitizeTextParts = true): unknown {
+ if (!Array.isArray(content)) {
+ return content;
+ }
+
+ let sanitizedContent = content;
+ const stack: ContentFrame[] = [
+ {
+ content,
+ sanitizeTextParts,
+ index: 0,
+ result: null,
+ onComplete: (result) => {
+ sanitizedContent = result;
+ },
+ },
+ ];
+
+ while (stack.length > 0) {
+ const frame = stack[stack.length - 1];
+ if (frame.index >= frame.content.length) {
+ stack.pop();
+ frame.onComplete(frame.result ?? frame.content);
+ continue;
+ }
+
+ const index = frame.index++;
+ const part = frame.content[index];
+ if (part == null || typeof part !== 'object') {
+ continue;
+ }
+
+ let sanitizedPart: unknown = part;
+ const record = part as Record;
+ if (frame.sanitizeTextParts && record.type === ContentTypes.TEXT) {
+ sanitizedPart = sanitizeTextPart(record);
+ }
+
+ const current = sanitizedPart as Record;
+ if (current.tool_call == null || typeof current.tool_call !== 'object') {
+ updateFramePart(frame, index, part, sanitizedPart);
+ continue;
+ }
+
+ const toolCall = current.tool_call as Record;
+ let sanitizedToolCall = toolCall;
+ if (toolCall.name === Constants.SUBAGENT && typeof toolCall.output === 'string') {
+ const output = stripUIResourceMarkers(toolCall.output);
+ if (output !== toolCall.output) {
+ sanitizedToolCall = { ...sanitizedToolCall, output };
+ }
+ }
+
+ if (Array.isArray(toolCall.subagent_content)) {
+ const originalSubagentContent = toolCall.subagent_content;
+ stack.push({
+ content: originalSubagentContent,
+ sanitizeTextParts: true,
+ index: 0,
+ result: null,
+ onComplete: (subagentContent) => {
+ if (subagentContent !== originalSubagentContent) {
+ sanitizedToolCall = { ...sanitizedToolCall, subagent_content: subagentContent };
+ }
+ if (sanitizedToolCall !== toolCall) {
+ sanitizedPart = { ...current, tool_call: sanitizedToolCall };
+ }
+ updateFramePart(frame, index, part, sanitizedPart);
+ },
+ });
+ continue;
+ }
+
+ if (sanitizedToolCall !== toolCall) {
+ sanitizedPart = { ...current, tool_call: sanitizedToolCall };
+ }
+ updateFramePart(frame, index, part, sanitizedPart);
+ }
+
+ return sanitizedContent;
+}
diff --git a/packages/data-schemas/tsdown.config.mjs b/packages/data-schemas/tsdown.config.mjs
index 33cfa59952..9a9556f88a 100644
--- a/packages/data-schemas/tsdown.config.mjs
+++ b/packages/data-schemas/tsdown.config.mjs
@@ -1,6 +1,11 @@
import path from 'node:path';
import { defineConfig } from 'tsdown';
+const isMarkdownParserDependency = (id) =>
+ /^(?:mdast-util-|micromark(?:-|$)|decode-named-character-reference$|devlop$|longest-streak$|unist-util-|zwitch$|character-(?:entities|reference)(?:-|$)|parse-entities$|stringify-entities$|is-(?:alphanumerical|alphabetical|decimal|hexadecimal)$|ccount$|markdown-table$|escape-string-regexp$)/.test(
+ id,
+ );
+
export default defineConfig({
entry: ['src/index.ts', 'src/admin/capabilities.ts'],
format: ['esm', 'cjs'],
@@ -10,13 +15,15 @@ export default defineConfig({
sourcemap: true,
// Warn on module cycles at build time; CI enforces via config/circular-deps.mjs.
checks: { circularDependency: true },
- // Externalize all third-party deps (consumers provide the peers); bundle only `dotenv`
- // so the package stays self-contained for its env-loading side effect, matching the
- // prior Rollup build. `neverBundle` is the 0.22 replacement for the deprecated `external`.
+ // Externalize third-party deps consumers provide, while bundling `dotenv` for its
+ // env-loading side effect and the ESM-only Markdown parser for CommonJS consumers.
+ // `neverBundle` is the 0.22 replacement for the deprecated `external`.
deps: {
+ alwaysBundle: isMarkdownParserDependency,
neverBundle: (id) =>
id !== 'dotenv' &&
!id.startsWith('dotenv/') &&
+ !isMarkdownParserDependency(id) &&
!id.startsWith('.') &&
!id.startsWith('~') &&
!path.isAbsolute(id),