From 9bd887b0d92320cecbcb371a113a869e25ced9ad Mon Sep 17 00:00:00 2001
From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
Date: Sun, 9 Aug 2026 17:43:47 -0700
Subject: [PATCH] fix(mcp): extract apps for any provider, bound path
templates, prevent sandbox self-navigation
Stop app extraction from depending on the image-formatting provider allowlist. A tool run through a
provider outside RECOGNIZED_PROVIDERS (vertexai, moonshot) had every artifact discarded, so declared
and embedded apps were reduced to plain text and the whole app HTML was pushed into model context.
Apps are now extracted for any provider while image formatting stays provider-gated, and the
synthesis path is gated on the per-request apps setting like the embedded path already was.
Drop an un-profiled resource returned at the tool-declared app URI. Such an echo suppressed the
declared app and rendered an inert static frame instead of initializing the App Bridge; suppression
now requires the returned resource to carry the mcp-app profile, and the synthetic app no longer
persists sibling bodies.
Bound RFC 6570 path-segment expansions by declared variable count and explode modifier. A template
such as files://root{/id} compiled to unbounded repetition, so files://root/private/secret passed the
advertised-template check that authorizes app-driven resources/read.
Apply a frame-src policy in the sandbox proxy, widened only to the resource's declared frameDomains,
so an app cannot swap a remote document into its own frame and keep posting bridge requests during
the window before the load-based teardown observes the navigation.
Redact resource _meta from shared transcripts, including copies nested in the content snapshot, for
the same reason resultMeta is already redacted: it is free-form server data the host never reads back
and the render path uses the explicit csp and permissions fields.
---
client/public/mcp-sandbox.html | 17 ++
packages/api/src/mcp/MCPManager.ts | 20 +-
.../api/src/mcp/__tests__/MCPManager.test.ts | 58 ++++
.../api/src/mcp/__tests__/parsers.test.ts | 258 ++++++++++++++++++
packages/api/src/mcp/parsers.ts | 39 ++-
.../data-schemas/src/methods/share.test.ts | 151 ++++++++++
packages/data-schemas/src/methods/share.ts | 28 +-
7 files changed, 558 insertions(+), 13 deletions(-)
diff --git a/client/public/mcp-sandbox.html b/client/public/mcp-sandbox.html
index 6073e05003..022f8c6f04 100644
--- a/client/public/mcp-sandbox.html
+++ b/client/public/mcp-sandbox.html
@@ -33,6 +33,7 @@
let innerFrameBlobUrl = null;
let innerFrameNavigated = false;
let readyInterval = null;
+ let navPolicyApplied = false;
const SANDBOX_PREFIX = 'ui/notifications/sandbox-';
// Default to same-origin. When the sandbox is served from a dedicated origin, the parent
@@ -105,6 +106,7 @@
function createInnerFrame(params) {
const { html, csp, permissions } = params;
+ applyNavigationPolicy(csp);
if (innerFrameBlobUrl) {
URL.revokeObjectURL(innerFrameBlobUrl);
@@ -204,6 +206,21 @@
return '';
}
+ // The inner navigable is bound by this document's frame-src (and by the copy the blob doc
+ // inherits), which is what stops an allow-scripts app from swapping in a remote document
+ // that would still satisfy the contentWindow source check. Widened only to the resource's
+ // declared frameDomains so nested iframes keep working; applied once, since policies are
+ // additive and a second policy would intersect with the first.
+ function applyNavigationPolicy(csp) {
+ if (navPolicyApplied) return;
+ navPolicyApplied = true;
+ const frameDomains = toDomainList(csp && typeof csp === 'object' ? csp.frameDomains : null);
+ const meta = document.createElement('meta');
+ meta.httpEquiv = 'Content-Security-Policy';
+ meta.content = ('frame-src blob: ' + frameDomains).trim();
+ document.head.appendChild(meta);
+ }
+
function buildCspPolicy(csp) {
const resourceDomains = toDomainList(csp.resourceDomains);
const connectDomains = toDomainList(csp.connectDomains) || "'none'";
diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts
index 5bb2e19297..b8acefbec5 100644
--- a/packages/api/src/mcp/MCPManager.ts
+++ b/packages/api/src/mcp/MCPManager.ts
@@ -1201,13 +1201,25 @@ Please follow these instructions when using tools from the respective MCP server
// Variable names declared in this expansion (operator + `:prefix`/`*explode` modifiers
// stripped), used to constrain query expansions to their declared keys rather than an
// open query string.
- const keys = expr
+ const varSpecs = expr
.replace(/^[+#./;?&]/, '')
.split(',')
- .map((name) => name.split(/[:*]/)[0].trim())
+ .map((spec) => spec.trim())
+ .filter(Boolean);
+ const keys = varSpecs
+ .map((spec) => spec.split(/[:*]/)[0].trim())
.filter(Boolean)
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
+ // RFC 6570 3.2.5/3.2.6: each defined variable contributes exactly one prefixed component,
+ // so a non-exploded expression can never expand past its declared variable count.
+ const exploded = varSpecs.some((spec) => spec.endsWith('*'));
+ const bounded = (unit: string) => {
+ if (exploded) {
+ return `(?:${unit})+`;
+ }
+ return varSpecs.length > 1 ? `(?:${unit}){1,${varSpecs.length}}` : unit;
+ };
switch (op) {
case '+': // reserved expansion: may legitimately include "/"
pattern += '[^?#]+';
@@ -1216,10 +1228,10 @@ Please follow these instructions when using tools from the respective MCP server
pattern += '#[^\\s]*';
break;
case '/': // path segments
- pattern += '(?:/[^/?#]+)+';
+ pattern += bounded('/[^/?#]+');
break;
case '.': // label(s)
- pattern += '(?:\\.[^/?#]+)+';
+ pattern += bounded('\\.[^/?#]+');
break;
case ';': // path-style params
pattern += '(?:;[^/?#]+)+';
diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts
index ae3b75128d..24625c5583 100644
--- a/packages/api/src/mcp/__tests__/MCPManager.test.ts
+++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts
@@ -1824,6 +1824,64 @@ describe('MCPManager', () => {
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
expect(methods).toContain('resources/read');
});
+
+ const templateOnlyRequest = (uriTemplate: string) =>
+ jest.fn().mockImplementation((req: { method: string }) => {
+ if (req.method === 'resources/list') {
+ return Promise.resolve({ resources: [] });
+ }
+ if (req.method === 'resources/templates/list') {
+ return Promise.resolve({ resourceTemplates: [{ uriTemplate }] });
+ }
+ return Promise.resolve({ contents: [] });
+ });
+
+ const templateCases: Array<{ uriTemplate: string; uri: string; allowed: boolean }> = [
+ { uriTemplate: 'files://root{/id}', uri: 'files://root/private/secret', allowed: false },
+ { uriTemplate: 'files://root{/id}', uri: 'files://root/42', allowed: true },
+ { uriTemplate: 'files://root{/id}', uri: 'files://root/a,b', allowed: true },
+ { uriTemplate: 'files://root{/id*}', uri: 'files://root/a/b/c', allowed: true },
+ { uriTemplate: 'files://root{/a,b}', uri: 'files://root/x/y', allowed: true },
+ { uriTemplate: 'files://root{/a,b}', uri: 'files://root/x/y/z', allowed: false },
+ { uriTemplate: 'files://root{/id}/meta', uri: 'files://root/a/meta', allowed: true },
+ { uriTemplate: 'files://root{/id}/meta', uri: 'files://root/a/b/meta', allowed: false },
+ { uriTemplate: 'file://docs{+path}', uri: 'file://docs/deep/nested/x', allowed: true },
+ { uriTemplate: 'file://docs{+path}', uri: 'file://docs/%2e%2e%2fsecret', allowed: false },
+ { uriTemplate: 'x://a{.fmt}', uri: 'x://a.json', allowed: true },
+ { uriTemplate: 'x://a{.fmt}', uri: 'x://a.json.bak', allowed: true },
+ { uriTemplate: 'search://items?q={q}', uri: 'search://items?q=foo', allowed: true },
+ {
+ uriTemplate: 'search://items?q={q}',
+ uri: 'search://items?q=foo&admin=true',
+ allowed: false,
+ },
+ { uriTemplate: 'db://items/{id}', uri: 'db://items/42', allowed: true },
+ ];
+
+ it.each(templateCases)(
+ 'template $uriTemplate authorizes $uri: $allowed',
+ async ({ uriTemplate, uri, allowed }) => {
+ const request = templateOnlyRequest(uriTemplate);
+ const manager = await MCPManager.createInstance(newMCPServersConfig());
+ jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
+
+ const read = manager.readResource({
+ userId: 'user-123',
+ serverName: 'srv',
+ uri,
+ user: mockUser as IUser,
+ });
+
+ if (allowed) {
+ await read;
+ } else {
+ await expect(read).rejects.toThrow(/not advertised/);
+ }
+
+ const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
+ expect(methods.includes('resources/read')).toBe(allowed);
+ },
+ );
});
describe('getConnection', () => {
diff --git a/packages/api/src/mcp/__tests__/parsers.test.ts b/packages/api/src/mcp/__tests__/parsers.test.ts
index e31c24a6a9..014b1c98ac 100644
--- a/packages/api/src/mcp/__tests__/parsers.test.ts
+++ b/packages/api/src/mcp/__tests__/parsers.test.ts
@@ -464,6 +464,32 @@ describe('formatToolContent', () => {
expect(content).not.toContain('UI Resource Marker:');
});
+ it('does not synthesize the tool-declared app when apps are disabled for the scope', () => {
+ const result: t.MCPToolCallResponse = { content: [{ type: 'text', text: 'done' }] };
+
+ const [content, artifacts] = formatToolContent(result, 'openai', {
+ serverName: 'srv',
+ toolName: 'do_thing',
+ resourceUri: 'ui://app',
+ enableApps: false,
+ });
+
+ expect(artifacts?.ui_resources).toBeUndefined();
+ expect(content).toBe('done');
+ });
+
+ it('does not synthesize an app for an empty declared resourceUri', () => {
+ const result: t.MCPToolCallResponse = { content: [{ type: 'text', text: 'done' }] };
+
+ const [, artifacts] = formatToolContent(result, 'openai', {
+ serverName: 'srv',
+ toolName: 'do_thing',
+ resourceUri: '',
+ });
+
+ expect(artifacts?.ui_resources).toBeUndefined();
+ });
+
it('gives embedded ui:// resources distinct ids per tool result payload', () => {
const resourceIdFor = (sc: Record) =>
formatToolContent(
@@ -708,4 +734,236 @@ describe('formatToolContent', () => {
expect(artifacts).toBeUndefined();
});
});
+
+ describe('MCP apps on unrecognized providers', () => {
+ it('extracts an embedded app resource instead of dumping its html into the model text', () => {
+ const result: t.MCPToolCallResponse = {
+ content: [
+ { type: 'text', text: 'ok' },
+ {
+ type: 'resource',
+ resource: {
+ uri: 'ui://s/app',
+ mimeType: 'text/html;profile=mcp-app',
+ text: 'SECRET_BODY',
+ },
+ },
+ ],
+ };
+
+ const [content, artifacts] = formatToolContent(result, 'vertexai' as t.Provider, {
+ serverName: 's',
+ toolName: 't',
+ toolArgs: { a: 1 },
+ });
+
+ const data = artifacts?.ui_resources?.data ?? [];
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({ uri: 'ui://s/app', serverName: 's', toolName: 't' });
+ expect(data[0].content).toEqual(expect.any(Array));
+ expect(content).toMatch(/UI Resource Marker: \\ui\{[a-f0-9]{10}\}/);
+ expect(content).not.toContain('SECRET_BODY');
+ });
+
+ it('synthesizes the tool-declared app for an unrecognized provider', () => {
+ const [, artifacts] = formatToolContent(
+ { content: [{ type: 'text', text: 'done' }] },
+ 'vertexai' as t.Provider,
+ { serverName: 's', toolName: 't', resourceUri: 'ui://s/app' },
+ );
+
+ expect(artifacts?.ui_resources?.data).toMatchObject([
+ { uri: 'ui://s/app', mimeType: 'text/html;profile=mcp-app' },
+ ]);
+ });
+
+ it('keeps the plain string output when apps are disabled for the scope', () => {
+ const result: t.MCPToolCallResponse = {
+ content: [
+ {
+ type: 'resource',
+ resource: { uri: 'ui://app', mimeType: 'text/html;profile=mcp-app', text: 'hi
' },
+ },
+ ],
+ };
+
+ const [content, artifacts] = formatToolContent(result, 'vertexai' as t.Provider, {
+ serverName: 's',
+ toolName: 't',
+ enableApps: false,
+ });
+
+ expect(artifacts).toBeUndefined();
+ expect(content).toBe('hi
\nResource URI: ui://app\nType: text/html;profile=mcp-app');
+ });
+
+ it('leaves images stringified in the text when an app widens the extraction path', () => {
+ const result: t.MCPToolCallResponse = {
+ content: [
+ { type: 'image', data: 'base64data', mimeType: 'image/png' },
+ {
+ type: 'resource',
+ resource: { uri: 'ui://app', mimeType: 'text/html;profile=mcp-app', text: 'a
' },
+ },
+ ],
+ };
+
+ const [content, artifacts] = formatToolContent(result, 'vertexai' as t.Provider, {
+ serverName: 's',
+ toolName: 't',
+ });
+
+ expect(artifacts?.ui_resources).toBeDefined();
+ expect(artifacts?.content).toBeUndefined();
+ expect(content).toContain('base64data');
+ });
+
+ it('extracts a non-app ui:// resource without app-bridge metadata', () => {
+ const result: t.MCPToolCallResponse = {
+ content: [
+ {
+ type: 'resource',
+ resource: { uri: 'ui://s/doc', mimeType: 'text/html', text: 'doc
' },
+ },
+ ],
+ };
+
+ const [, artifacts] = formatToolContent(result, 'vertexai' as t.Provider, {
+ serverName: 's',
+ toolName: 't',
+ });
+
+ const resource = artifacts?.ui_resources?.data?.[0];
+ expect(resource).toMatchObject({ uri: 'ui://s/doc', mimeType: 'text/html' });
+ expect(resource?.serverName).toBeUndefined();
+ expect(resource?.toolName).toBeUndefined();
+ expect(resource?.content).toBeUndefined();
+ });
+ });
+
+ describe('un-profiled echo of the tool-declared app uri', () => {
+ const echoResult = (resource: Record): t.MCPToolCallResponse => ({
+ content: [{ type: 'resource', resource } as t.ToolContentPart],
+ });
+
+ const appMetadata = { serverName: 'srv', toolName: 'do_thing', resourceUri: 'ui://app' };
+
+ it('drops the static echo and renders only the declared app', () => {
+ const [content, artifacts] = formatToolContent(
+ echoResult({ uri: 'ui://app', mimeType: 'text/html', text: 'static
' }),
+ 'openai',
+ appMetadata,
+ );
+
+ const data = artifacts?.ui_resources?.data ?? [];
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({
+ uri: 'ui://app',
+ mimeType: 'text/html;profile=mcp-app',
+ serverName: 'srv',
+ toolName: 'do_thing',
+ });
+ expect(content.match(/UI Resource Marker:/g)).toHaveLength(1);
+ expect(content).toContain('Resource URI: ui://app');
+ expect(content).not.toContain('Resource Text:');
+ expect(content).not.toContain('static
');
+ });
+
+ it('drops the echo when its mime type is omitted entirely', () => {
+ const [content, artifacts] = formatToolContent(
+ echoResult({ uri: 'ui://app', text: 'static
' }),
+ 'openai',
+ appMetadata,
+ );
+
+ const data = artifacts?.ui_resources?.data ?? [];
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({ uri: 'ui://app', mimeType: 'text/html;profile=mcp-app' });
+ expect(content).not.toContain('static
');
+ });
+
+ it('renders the declared app when the echo carries no body at all', () => {
+ const [, artifacts] = formatToolContent(
+ echoResult({ uri: 'ui://app', mimeType: 'text/html' }),
+ 'openai',
+ appMetadata,
+ );
+
+ const data = artifacts?.ui_resources?.data ?? [];
+ expect(data).toHaveLength(1);
+ expect(data[0]).toMatchObject({
+ uri: 'ui://app',
+ mimeType: 'text/html;profile=mcp-app',
+ serverName: 'srv',
+ toolName: 'do_thing',
+ });
+ });
+
+ it('produces the same artifact on an unrecognized provider', () => {
+ const resource = { uri: 'ui://app', mimeType: 'text/html', text: 'static
' };
+ const [, recognized] = formatToolContent(echoResult(resource), 'openai', appMetadata);
+ const [, unrecognized] = formatToolContent(
+ echoResult(resource),
+ 'vertexai' as t.Provider,
+ appMetadata,
+ );
+
+ expect(unrecognized?.ui_resources).toEqual(recognized?.ui_resources);
+ });
+
+ it('keeps a different un-profiled ui:// resource alongside the declared app', () => {
+ const result: t.MCPToolCallResponse = {
+ content: [
+ {
+ type: 'resource',
+ resource: { uri: 'ui://chart', mimeType: 'text/html', text: 'chart
' },
+ },
+ ],
+ };
+
+ const [, artifacts] = formatToolContent(result, 'openai', appMetadata);
+
+ const data = artifacts?.ui_resources?.data ?? [];
+ expect(data).toHaveLength(2);
+ const chart = data.find((resource) => resource.uri === 'ui://chart');
+ expect(chart).toMatchObject({ mimeType: 'text/html' });
+ expect(chart?.serverName).toBeUndefined();
+ expect(chart?.toolName).toBeUndefined();
+ expect(data.some((resource) => resource.uri === 'ui://app')).toBe(true);
+ });
+
+ it('does not persist an embedded body on the synthesized app', () => {
+ const body = 'A'.repeat(5000);
+ const [, artifacts] = formatToolContent(
+ echoResult({ uri: 'ui://app', mimeType: 'text/html', text: body }),
+ 'openai',
+ appMetadata,
+ );
+
+ const snapshot = JSON.stringify(artifacts?.ui_resources?.data?.[0]?.content ?? []);
+ expect(snapshot).not.toContain(body);
+ expect(snapshot).toContain('ui://app');
+ });
+
+ it('does not persist a sibling app body on the synthesized app', () => {
+ const body = 'B'.repeat(5000);
+ const result: t.MCPToolCallResponse = {
+ content: [
+ {
+ type: 'resource',
+ resource: { uri: 'ui://chart', mimeType: 'text/html;profile=mcp-app', text: body },
+ },
+ ],
+ };
+
+ const [, artifacts] = formatToolContent(result, 'openai', appMetadata);
+
+ const synthetic = artifacts?.ui_resources?.data?.find(
+ (resource) => resource.uri === 'ui://app',
+ );
+ const snapshot = JSON.stringify(synthetic?.content ?? []);
+ expect(snapshot).not.toContain(body);
+ expect(snapshot).toContain('ui://chart');
+ });
+ });
});
diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts
index 0df853636f..7eaf70435c 100644
--- a/packages/api/src/mcp/parsers.ts
+++ b/packages/api/src/mcp/parsers.ts
@@ -231,13 +231,17 @@ export function formatToolContent(
enableApps?: boolean;
},
): t.FormattedContentResult {
- if (!RECOGNIZED_PROVIDERS.has(provider)) {
+ const isRecognizedProvider = RECOGNIZED_PROVIDERS.has(provider);
+ // Truthiness, not != null: an empty resourceUri/serverName/toolName cannot address an app, and a
+ // single predicate keeps this gate and the synthesis below from drifting apart.
+ const hasSyntheticApp = !!(metadata?.resourceUri && metadata.serverName && metadata.toolName);
+ const hasApp =
+ metadata?.enableApps !== false && (hasSyntheticApp || resultHasRenderableUiResource(result));
+ if (!isRecognizedProvider && !hasApp) {
return [parseAsString(result), undefined];
}
const content = result?.content ?? [];
- const hasSyntheticApp =
- metadata?.resourceUri != null && metadata.serverName != null && metadata.toolName != null;
if (!content.length && !hasSyntheticApp) {
return ['(No response)', undefined];
}
@@ -264,6 +268,10 @@ export function formatToolContent(
return;
}
assertImageDataWithinLimit(item);
+ if (!isRecognizedProvider) {
+ currentTextBlock += (currentTextBlock ? '\n\n' : '') + JSON.stringify(item, null, 2);
+ return;
+ }
const formatter = imageFormatters.default as t.ImageFormatter;
const formattedImage = formatter(item);
@@ -277,9 +285,14 @@ export function formatToolContent(
// scope with apps disabled fall through to plain resource text rather than an unrenderable
// or admin-suppressed app marker.
const isUiResource = metadata?.enableApps !== false && isRenderableUiResource(item);
+ const isUnprofiledDeclaredApp =
+ isUiResource &&
+ hasSyntheticApp &&
+ item.resource.uri === metadata?.resourceUri &&
+ !(item.resource.mimeType ?? '').includes('profile=mcp-app');
const resourceText: string[] = [];
- if (isUiResource) {
+ if (isUiResource && !isUnprofiledDeclaredApp) {
const baseHash =
'text' in item.resource && item.resource.text && typeof item.resource.text === 'string'
? item.resource.text
@@ -319,7 +332,12 @@ export function formatToolContent(
uiResources.push(uiResource);
resourceText.push(`UI Resource ID: ${resourceId}`);
resourceText.push(`UI Resource Marker: \\ui{${resourceId}}`);
- } else if ('text' in item.resource && item.resource.text != null && item.resource.text) {
+ } else if (
+ !isUnprofiledDeclaredApp &&
+ 'text' in item.resource &&
+ item.resource.text != null &&
+ item.resource.text
+ ) {
resourceText.push(`Resource Text: ${item.resource.text}`);
}
@@ -350,8 +368,15 @@ export function formatToolContent(
// synthesize it unless the result already returned that exact resource. A secondary ui://
// resource in the result must not suppress the declared app.
const declaredAppAlreadyReturned =
- metadata?.resourceUri != null && uiResources.some((r) => r.uri === metadata.resourceUri);
+ metadata?.resourceUri != null &&
+ uiResources.some(
+ (r) => r.uri === metadata.resourceUri && (r.mimeType ?? '').includes('profile=mcp-app'),
+ );
+ // Gated on the per-request apps setting for the same reason the embedded path is: a scope with
+ // apps disabled must not get a synthesized app either.
if (
+ hasSyntheticApp &&
+ metadata?.enableApps !== false &&
metadata?.resourceUri &&
metadata.serverName &&
metadata.toolName &&
@@ -371,7 +396,7 @@ export function formatToolContent(
serverName: metadata.serverName,
toolName: metadata.toolName,
structuredContent: result?.structuredContent,
- content: result?.content,
+ content: sharedResultContent,
csp: metadata.csp,
permissions: metadata.permissions,
toolArgs: metadata.toolArgs,
diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts
index c66aa29e07..4eebe4ef35 100644
--- a/packages/data-schemas/src/methods/share.test.ts
+++ b/packages/data-schemas/src/methods/share.test.ts
@@ -707,6 +707,157 @@ describe('Share Methods', () => {
expect(resource).not.toHaveProperty('resultMeta');
});
+ test('strips resource _meta from shared ui_resources while keeping every render field', 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: [
+ {
+ resourceId: 'res1',
+ uri: 'ui://app',
+ name: 'App',
+ mimeType: 'text/html;profile=mcp-app',
+ text: 'hi
',
+ blob: 'PHA+aGk8L3A+',
+ csp: { connectDomains: ['https://api.example.com'] },
+ permissions: { clipboardWrite: true },
+ serverName: 'srv',
+ toolName: 'do_thing',
+ isError: false,
+ _meta: { secret: 'resource-level' },
+ content: [
+ { type: 'text', text: 'result text', _meta: { secret: 'part-level' } },
+ {
+ type: 'resource',
+ _meta: { secret: 'sibling-part-level' },
+ resource: {
+ uri: 'ui://other',
+ mimeType: 'text/html',
+ text: 'sibling
',
+ _meta: { secret: 'sibling-resource-level' },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ 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?: Array>;
+ };
+ const resource = attachment?.ui_resources?.[0];
+ expect(resource).toMatchObject({
+ resourceId: 'res1',
+ uri: 'ui://app',
+ name: 'App',
+ mimeType: 'text/html;profile=mcp-app',
+ text: 'hi
',
+ blob: 'PHA+aGk8L3A+',
+ csp: { connectDomains: ['https://api.example.com'] },
+ permissions: { clipboardWrite: true },
+ serverName: 'srv',
+ toolName: 'do_thing',
+ isError: false,
+ });
+ expect(resource).not.toHaveProperty('_meta');
+
+ const parts = resource?.content as Array>;
+ expect(parts[0]).toEqual({ type: 'text', text: 'result text' });
+ expect(parts[1]).not.toHaveProperty('_meta');
+ expect(parts[1].resource).toMatchObject({ uri: 'ui://other', mimeType: 'text/html' });
+ expect(parts[1].resource).not.toHaveProperty('_meta');
+ expect(JSON.stringify(resource)).not.toContain('secret');
+ });
+
+ test('strips resource _meta 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
',
+ _meta: { secret: 'resource-level' },
+ content: [
+ {
+ type: 'resource',
+ resource: { uri: 'ui://app', _meta: { secret: 'nested' } },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ 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('_meta');
+ expect(JSON.stringify(resource)).not.toContain('secret');
+ });
+
+ test('passes through ui_resources values that are neither an array nor { 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: null },
+ { type: 'ui_resources', ui_resources: 'not-a-resource-list' },
+ ],
+ });
+
+ await SharedLink.create({ shareId, conversationId, user: userId, messages: [message._id] });
+
+ const result = await shareMethods.getSharedMessages(shareId);
+ const attachments = result?.messages[0]?.attachments as unknown as Array<
+ Record
+ >;
+ expect(attachments).toHaveLength(2);
+ expect(attachments[1].ui_resources).toBe('not-a-resource-list');
+ });
+
test('strips steer-part files from content when the link excludes files', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const conversationId = `conv_${nanoid()}`;
diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts
index c95111e929..991362ab63 100644
--- a/packages/data-schemas/src/methods/share.ts
+++ b/packages/data-schemas/src/methods/share.ts
@@ -87,21 +87,45 @@ const SENSITIVE_SHARED_FILE_FIELDS = new Set([
'metadata',
]);
+/** Drops `_meta` from a copied MCP content block and its embedded resource. */
+function sanitizeSharedContentPart(part: unknown): unknown {
+ if (!part || typeof part !== 'object' || Array.isArray(part)) {
+ return part;
+ }
+ const { _meta: _partMeta, ...rest } = part as Record;
+ const resource = rest.resource;
+ if (resource && typeof resource === 'object' && !Array.isArray(resource)) {
+ const { _meta: _resourceMeta, ...resourceRest } = resource as Record;
+ rest.resource = resourceRest;
+ }
+ return rest;
+}
+
/**
* The MCP tool result `_meta` is carried on a UI resource as `resultMeta` for the App Bridge to
* hydrate from, but it is intentionally kept out of the model-visible result and must not become
* part of a public shared transcript. MCP apps never render in a shared view anyway, so drop it.
+ * The resource's own `_meta` (and that of every block in the copied tool result) is the same kind
+ * of free-form server-controlled bag and is dropped for the same reason; the shared view renders
+ * from the explicit `csp`/`permissions` fields, never from `_meta`.
*/
function sanitizeSharedUIResource(resource: unknown): unknown {
if (!resource || typeof resource !== 'object' || Array.isArray(resource)) {
return resource;
}
- const { resultMeta: _resultMeta, ...rest } = resource as Record;
+ const {
+ resultMeta: _resultMeta,
+ _meta: _resourceMeta,
+ ...rest
+ } = resource as Record;
+ if (Array.isArray(rest.content)) {
+ rest.content = rest.content.map(sanitizeSharedContentPart);
+ }
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. */
+ * shape; sanitize both so redacted fields never reach a shared transcript. */
function sanitizeSharedUIResources(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sanitizeSharedUIResource);