From 809ad310a983d0d332bb59217bed4abf3f0882fc Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:40:28 -0700 Subject: [PATCH] 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. --- .../Chat/Messages/Content/ToolCall.tsx | 4 +- client/src/hooks/MCP/useAppBridge.ts | 17 ++++++-- client/src/utils/mcpApps.ts | 11 +++-- .../data-schemas/src/methods/share.test.ts | 40 +++++++++++++++++++ packages/data-schemas/src/methods/share.ts | 18 +++++++-- 5 files changed, 78 insertions(+), 12 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index a214a2dc3d..6763df3868 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -405,7 +405,9 @@ export default function ToolCall({ )} {mcpApps.length > 0 && - mcpApps.map((app) => )} + mcpApps.map((app) => ( + + ))} ); } diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index d170d5fb05..c38d57f4c2 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -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, diff --git a/client/src/utils/mcpApps.ts b/client/src/utils/mcpApps.ts index 78d16781f5..21cdd15708 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -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 = ''; } diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index 1b0ad7b831..6dc9f5ee43 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -583,6 +583,46 @@ describe('Share Methods', () => { expect(resource).toMatchObject({ uri: 'ui://app', resourceId: 'res1', text: '

hi

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

hi

', + 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> }; + }; + const resource = attachment?.ui_resources?.data?.[0]; + expect(resource).toMatchObject({ uri: 'ui://app', resourceId: 'res1', text: '

hi

' }); + expect(resource).not.toHaveProperty('resultMeta'); + }); }); describe('getSharedLinks', () => { diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index a0dee47296..2781bd06e7 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -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;