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