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