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:
Dustin Healy 2026-08-04 04:40:28 -07:00
parent 817e35561a
commit 809ad310a9
5 changed files with 78 additions and 12 deletions

View file

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

View file

@ -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,

View file

@ -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 = '';
}

View file

@ -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', () => {

View file

@ -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;