mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix(mcp): decode blob app HTML, sanitize object-shaped ui_resources, use persisted toolArgs
Decode a base64 blob-embedded app resource as inline HTML in useAppBridge so blob apps render in
read-only views and skip the resources/read fallback (shared decodeBase64Utf8 helper reused by
fetchMCPResourceHtml).
Redact resultMeta from the object-shaped ({ data: [...] }) ui_resources attachment during share
serialization, not just the bare-array shape.
Prefer the persisted UIResource.toolArgs over the display tool-call args when auto-rendering an app
so it hydrates with the exact initial input.
This commit is contained in:
parent
817e35561a
commit
809ad310a9
5 changed files with 78 additions and 12 deletions
|
|
@ -405,7 +405,9 @@ export default function ToolCall({
|
|||
<AttachmentGroup attachments={attachments} />
|
||||
)}
|
||||
{mcpApps.length > 0 &&
|
||||
mcpApps.map((app) => <MCPAppView key={app.resourceId} app={app} args={_args} />)}
|
||||
mcpApps.map((app) => (
|
||||
<MCPAppView key={app.resourceId} app={app} args={app.toolArgs ?? _args} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
readMCPResource,
|
||||
listMCPResources,
|
||||
listMCPResourceTemplates,
|
||||
decodeBase64Utf8,
|
||||
} from '~/utils/mcpApps';
|
||||
import { useOptionalMessagesOperations, useIsMessagesViewReadOnly } from '~/Providers';
|
||||
import { logger } from '~/utils';
|
||||
|
|
@ -189,9 +190,19 @@ export function useAppBridge(
|
|||
return;
|
||||
}
|
||||
sandboxReadyHandled = true;
|
||||
// Inline HTML may arrive as `text` or as a base64 `blob`; decode the blob so blob-embedded
|
||||
// apps are treated as persisted (rendered in read-only) rather than resourceUri-only.
|
||||
let inlineHtml = resource.text;
|
||||
if (!inlineHtml && typeof resource.blob === 'string' && resource.blob) {
|
||||
try {
|
||||
inlineHtml = decodeBase64Utf8(resource.blob);
|
||||
} catch {
|
||||
inlineHtml = undefined;
|
||||
}
|
||||
}
|
||||
// Read-only views must not resolve app HTML from the viewer's MCP server, so only inline
|
||||
// (persisted) HTML renders here.
|
||||
if (!resource.text && readOnlyRef.current) {
|
||||
if (!inlineHtml && readOnlyRef.current) {
|
||||
logger.debug(
|
||||
'[MCP App] Read-only view: skipping server HTML fetch for resourceUri-only app',
|
||||
);
|
||||
|
|
@ -200,8 +211,8 @@ export function useAppBridge(
|
|||
try {
|
||||
// Inline mcp-app resources already carry their HTML, so use it directly instead of a
|
||||
// resources/read round trip; resourceUri-only apps are fetched from the server.
|
||||
const { html, csp, permissions } = resource.text
|
||||
? { html: resource.text, csp: resource.csp, permissions: resource.permissions }
|
||||
const { html, csp, permissions } = inlineHtml
|
||||
? { html: inlineHtml, csp: resource.csp, permissions: resource.permissions }
|
||||
: await queryClient.fetchQuery({
|
||||
queryKey: [
|
||||
QueryKeys.mcpAppResourceHtml,
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ type ResourceUiMeta = {
|
|||
};
|
||||
};
|
||||
|
||||
/** Decode a base64 resource blob as UTF-8 so non-ASCII HTML is not mojibake (atob yields Latin-1). */
|
||||
export function decodeBase64Utf8(b64: string): string {
|
||||
const bytes = Uint8Array.from(atob(b64), (char) => char.charCodeAt(0));
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
|
||||
export async function fetchMCPResourceHtml(
|
||||
serverName: string,
|
||||
uri: string,
|
||||
|
|
@ -121,10 +127,7 @@ export async function fetchMCPResourceHtml(
|
|||
let html = item?.text ?? '';
|
||||
if (!html && typeof item?.blob === 'string' && item.blob) {
|
||||
try {
|
||||
// Decode base64 as UTF-8 so non-ASCII HTML (localized text, inline JSON) is not mojibake;
|
||||
// atob alone yields a Latin-1 string.
|
||||
const bytes = Uint8Array.from(atob(item.blob), (char) => char.charCodeAt(0));
|
||||
html = new TextDecoder('utf-8').decode(bytes);
|
||||
html = decodeBase64Utf8(item.blob);
|
||||
} catch {
|
||||
html = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -583,6 +583,46 @@ describe('Share Methods', () => {
|
|||
expect(resource).toMatchObject({ uri: 'ui://app', resourceId: 'res1', text: '<p>hi</p>' });
|
||||
expect(resource).not.toHaveProperty('resultMeta');
|
||||
});
|
||||
|
||||
test('strips resultMeta from object-shaped ui_resources ({ data: [...] })', 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: 'has app',
|
||||
isCreatedByUser: false,
|
||||
attachments: [
|
||||
{
|
||||
type: 'ui_resources',
|
||||
ui_resources: {
|
||||
data: [
|
||||
{
|
||||
resourceId: 'res1',
|
||||
uri: 'ui://app',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
text: '<p>hi</p>',
|
||||
resultMeta: { secret: 'hidden-from-model' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await SharedLink.create({ shareId, conversationId, user: userId, messages: [message._id] });
|
||||
|
||||
const result = await shareMethods.getSharedMessages(shareId);
|
||||
const attachment = result?.messages[0]?.attachments?.[0] as unknown as {
|
||||
ui_resources?: { data?: Array<Record<string, unknown>> };
|
||||
};
|
||||
const resource = attachment?.ui_resources?.data?.[0];
|
||||
expect(resource).toMatchObject({ uri: 'ui://app', resourceId: 'res1', text: '<p>hi</p>' });
|
||||
expect(resource).not.toHaveProperty('resultMeta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedLinks', () => {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@ function sanitizeSharedUIResource(resource: unknown): unknown {
|
|||
return rest;
|
||||
}
|
||||
|
||||
/** ui_resources is stored either as a bare array or as the `{ data: UIResource[] }` artifact
|
||||
* shape; redact resultMeta in both so it never reaches a shared transcript. */
|
||||
function sanitizeSharedUIResources(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sanitizeSharedUIResource);
|
||||
}
|
||||
if (value && typeof value === 'object' && Array.isArray((value as { data?: unknown }).data)) {
|
||||
const obj = value as { data: unknown[] };
|
||||
return { ...obj, data: obj.data.map(sanitizeSharedUIResource) };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip storage/identity-internal fields from a file or attachment while keeping
|
||||
* render-relevant data (including tool-call payloads keyed by tool name).
|
||||
|
|
@ -94,10 +107,7 @@ function sanitizeSharedFile(value: unknown): t.SharedFile | null {
|
|||
if (SENSITIVE_SHARED_FILE_FIELDS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
result[key] =
|
||||
key === 'ui_resources' && Array.isArray(fieldValue)
|
||||
? fieldValue.map(sanitizeSharedUIResource)
|
||||
: fieldValue;
|
||||
result[key] = key === 'ui_resources' ? sanitizeSharedUIResources(fieldValue) : fieldValue;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue