feat(mcp-apps): add Permission Policy support for sandbox iframes (#6947)

This commit is contained in:
Andrew Harvard
2026-02-04 11:52:11 -05:00
committed by GitHub
parent d8d9bb741b
commit 2b90c2c310
10 changed files with 110 additions and 8 deletions
+1
View File
@@ -576,6 +576,7 @@ derive_utoipa!(Icon as IconSchema);
goose::goose_apps::WindowProps, goose::goose_apps::WindowProps,
goose::goose_apps::McpAppResource, goose::goose_apps::McpAppResource,
goose::goose_apps::CspMetadata, goose::goose_apps::CspMetadata,
goose::goose_apps::PermissionsMetadata,
goose::goose_apps::UiMetadata, goose::goose_apps::UiMetadata,
goose::goose_apps::ResourceMetadata, goose::goose_apps::ResourceMetadata,
super::routes::dictation::TranscribeRequest, super::routes::dictation::TranscribeRequest,
@@ -33,7 +33,7 @@
let guestIframe = null; let guestIframe = null;
function createGuestIframe(html) { function createGuestIframe(html, permissions) {
if (guestIframe) { if (guestIframe) {
guestIframe.remove(); guestIframe.remove();
} }
@@ -46,6 +46,17 @@
// allow-forms: needed if the app has forms // allow-forms: needed if the app has forms
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
// Build Permission Policy allow attribute from requested permissions
// These control access to sensitive browser APIs like camera, microphone, etc.
var allowList = [];
if (permissions && permissions.camera) allowList.push('camera');
if (permissions && permissions.microphone) allowList.push('microphone');
if (permissions && permissions.geolocation) allowList.push('geolocation');
if (permissions && permissions.clipboardWrite) allowList.push('clipboard-write');
if (allowList.length > 0) {
guestIframe.setAttribute('allow', allowList.join('; '));
}
guestIframe.srcdoc = html; guestIframe.srcdoc = html;
guestIframe.style.cssText = 'width:100%; height:100%; border:none;'; guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
@@ -73,8 +84,9 @@
if (method === 'ui/notifications/sandbox-resource-ready') { if (method === 'ui/notifications/sandbox-resource-ready') {
var params = data.params || {}; var params = data.params || {};
var html = params.html || ''; var html = params.html || '';
var permissions = params.permissions || null;
createGuestIframe(html); createGuestIframe(html, permissions);
return; return;
} }
@@ -132,4 +144,4 @@
})(); })();
</script> </script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -4,4 +4,6 @@ pub mod resource;
pub use app::{fetch_mcp_apps, GooseApp, WindowProps}; pub use app::{fetch_mcp_apps, GooseApp, WindowProps};
pub use cache::McpAppCache; pub use cache::McpAppCache;
pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata}; pub use resource::{
CspMetadata, McpAppResource, PermissionsMetadata, ResourceMetadata, UiMetadata,
};
+28
View File
@@ -14,6 +14,30 @@ pub struct CspMetadata {
pub resource_domains: Option<Vec<String>>, pub resource_domains: Option<Vec<String>>,
} }
/// Sandbox permissions for MCP Apps
/// Specifies which browser capabilities the UI needs access to.
/// Maps to the iframe Permission Policy `allow` attribute.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct PermissionsMetadata {
/// Request camera access (maps to Permission Policy `camera` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub camera: bool,
/// Request microphone access (maps to Permission Policy `microphone` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub microphone: bool,
/// Request geolocation access (maps to Permission Policy `geolocation` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub geolocation: bool,
/// Request clipboard write access (maps to Permission Policy `clipboard-write` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub clipboard_write: bool,
}
fn is_default_permissions(p: &PermissionsMetadata) -> bool {
*p == PermissionsMetadata::default()
}
/// UI-specific metadata for MCP resources /// UI-specific metadata for MCP resources
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -21,6 +45,9 @@ pub struct UiMetadata {
/// Content Security Policy configuration /// Content Security Policy configuration
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub csp: Option<CspMetadata>, pub csp: Option<CspMetadata>,
/// Sandbox permissions requested by the UI
#[serde(default, skip_serializing_if = "is_default_permissions")]
pub permissions: PermissionsMetadata,
/// Preferred domain for the app (used for CORS) /// Preferred domain for the app (used for CORS)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>, pub domain: Option<String>,
@@ -87,6 +114,7 @@ impl McpAppResource {
meta: Some(ResourceMetadata { meta: Some(ResourceMetadata {
ui: Some(UiMetadata { ui: Some(UiMetadata {
csp: Some(csp), csp: Some(csp),
permissions: PermissionsMetadata::default(),
domain: None, domain: None,
prefers_border: None, prefers_border: None,
}), }),
+25
View File
@@ -5249,6 +5249,28 @@
"never_allow" "never_allow"
] ]
}, },
"PermissionsMetadata": {
"type": "object",
"description": "Sandbox permissions for MCP Apps\nSpecifies which browser capabilities the UI needs access to.\nMaps to the iframe Permission Policy `allow` attribute.",
"properties": {
"camera": {
"type": "boolean",
"description": "Request camera access (maps to Permission Policy `camera` feature)"
},
"clipboardWrite": {
"type": "boolean",
"description": "Request clipboard write access (maps to Permission Policy `clipboard-write` feature)"
},
"geolocation": {
"type": "boolean",
"description": "Request geolocation access (maps to Permission Policy `geolocation` feature)"
},
"microphone": {
"type": "boolean",
"description": "Request microphone access (maps to Permission Policy `microphone` feature)"
}
}
},
"PricingData": { "PricingData": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -7021,6 +7043,9 @@
"description": "Preferred domain for the app (used for CORS)", "description": "Preferred domain for the app (used for CORS)",
"nullable": true "nullable": true
}, },
"permissions": {
"$ref": "#/components/schemas/PermissionsMetadata"
},
"prefersBorder": { "prefersBorder": {
"type": "boolean", "type": "boolean",
"description": "Whether the app prefers to have a border around it", "description": "Whether the app prefers to have a border around it",
File diff suppressed because one or more lines are too long
+25
View File
@@ -667,6 +667,30 @@ export type ParseRecipeResponse = {
*/ */
export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow'; export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow';
/**
* Sandbox permissions for MCP Apps
* Specifies which browser capabilities the UI needs access to.
* Maps to the iframe Permission Policy `allow` attribute.
*/
export type PermissionsMetadata = {
/**
* Request camera access (maps to Permission Policy `camera` feature)
*/
camera?: boolean;
/**
* Request clipboard write access (maps to Permission Policy `clipboard-write` feature)
*/
clipboardWrite?: boolean;
/**
* Request geolocation access (maps to Permission Policy `geolocation` feature)
*/
geolocation?: boolean;
/**
* Request microphone access (maps to Permission Policy `microphone` feature)
*/
microphone?: boolean;
};
export type PricingData = { export type PricingData = {
context_length?: number | null; context_length?: number | null;
currency: string; currency: string;
@@ -1273,6 +1297,7 @@ export type UiMetadata = {
* Preferred domain for the app (used for CORS) * Preferred domain for the app (used for CORS)
*/ */
domain?: string | null; domain?: string | null;
permissions?: PermissionsMetadata;
/** /**
* Whether the app prefers to have a border around it * Whether the app prefers to have a border around it
*/ */
@@ -15,6 +15,7 @@ import {
ToolResult, ToolResult,
ToolCancelled, ToolCancelled,
CspMetadata, CspMetadata,
PermissionsMetadata,
McpMethodParams, McpMethodParams,
McpMethodResponse, McpMethodResponse,
} from './types'; } from './types';
@@ -40,6 +41,7 @@ interface McpAppRendererProps {
interface ResourceData { interface ResourceData {
html: string | null; html: string | null;
csp: CspMetadata | null; csp: CspMetadata | null;
permissions: PermissionsMetadata | null;
prefersBorder: boolean; prefersBorder: boolean;
} }
@@ -58,6 +60,7 @@ export default function McpAppRenderer({
const [resource, setResource] = useState<ResourceData>({ const [resource, setResource] = useState<ResourceData>({
html: cachedHtml || null, html: cachedHtml || null,
csp: null, csp: null,
permissions: null,
prefersBorder: true, prefersBorder: true,
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -82,13 +85,14 @@ export default function McpAppRenderer({
if (response.data) { if (response.data) {
const content = response.data; const content = response.data;
const meta = content._meta as const meta = content._meta as
| { ui?: { csp?: CspMetadata; prefersBorder?: boolean } } | { ui?: { csp?: CspMetadata; permissions?: PermissionsMetadata; prefersBorder?: boolean } }
| undefined; | undefined;
if (content.text !== cachedHtml) { if (content.text !== cachedHtml) {
setResource({ setResource({
html: content.text, html: content.text,
csp: meta?.ui?.csp || null, csp: meta?.ui?.csp || null,
permissions: meta?.ui?.permissions || null,
prefersBorder: meta?.ui?.prefersBorder ?? true, prefersBorder: meta?.ui?.prefersBorder ?? true,
}); });
} }
@@ -241,6 +245,7 @@ export default function McpAppRenderer({
const { iframeRef, proxyUrl } = useSandboxBridge({ const { iframeRef, proxyUrl } = useSandboxBridge({
resourceHtml: resource.html || '', resourceHtml: resource.html || '',
resourceCsp: resource.csp, resourceCsp: resource.csp,
resourcePermissions: resource.permissions,
resourceUri, resourceUri,
toolInput, toolInput,
toolInputPartial, toolInputPartial,
+1 -1
View File
@@ -1,4 +1,4 @@
export type { CspMetadata, CallToolResponse as ToolResult } from '../../api/types.gen'; export type { CspMetadata, PermissionsMetadata, CallToolResponse as ToolResult } from '../../api/types.gen';
export type ContentBlock = export type ContentBlock =
| { type: 'text'; text: string } | { type: 'text'; text: string }
@@ -9,6 +9,7 @@ import type {
ToolCancelled, ToolCancelled,
HostContext, HostContext,
CspMetadata, CspMetadata,
PermissionsMetadata,
} from './types'; } from './types';
import { fetchMcpAppProxyUrl } from './utils'; import { fetchMcpAppProxyUrl } from './utils';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@@ -18,6 +19,7 @@ import { errorMessage } from '../../utils/conversionUtils';
interface SandboxBridgeOptions { interface SandboxBridgeOptions {
resourceHtml: string; resourceHtml: string;
resourceCsp: CspMetadata | null; resourceCsp: CspMetadata | null;
resourcePermissions: PermissionsMetadata | null;
resourceUri: string; resourceUri: string;
toolInput?: ToolInput; toolInput?: ToolInput;
toolInputPartial?: ToolInputPartial; toolInputPartial?: ToolInputPartial;
@@ -40,6 +42,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
const { const {
resourceHtml, resourceHtml,
resourceCsp, resourceCsp,
resourcePermissions,
resourceUri, resourceUri,
toolInput, toolInput,
toolInputPartial, toolInputPartial,
@@ -80,7 +83,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
sendToSandbox({ sendToSandbox({
jsonrpc: '2.0', jsonrpc: '2.0',
method: 'ui/notifications/sandbox-resource-ready', method: 'ui/notifications/sandbox-resource-ready',
params: { html: resourceHtml, csp: resourceCsp }, params: { html: resourceHtml, csp: resourceCsp, permissions: resourcePermissions },
}); });
break; break;
@@ -181,6 +184,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
[ [
resourceHtml, resourceHtml,
resourceCsp, resourceCsp,
resourcePermissions,
resolvedTheme, resolvedTheme,
sendToSandbox, sendToSandbox,
onMcpRequest, onMcpRequest,