LibreChat/client/src/components/MCPUIResource/plugin.ts
Samuel Path 304bba853c
💻 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>
2025-12-11 16:41:11 -05:00

91 lines
2.6 KiB
TypeScript

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