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;