diff --git a/crates/goose-server/src/routes/templates/mcp_app_proxy.html b/crates/goose-server/src/routes/templates/mcp_app_proxy.html index 07d91505c103..28fc99194cf0 100644 --- a/crates/goose-server/src/routes/templates/mcp_app_proxy.html +++ b/crates/goose-server/src/routes/templates/mcp_app_proxy.html @@ -138,7 +138,7 @@ .parent .postMessage({ jsonrpc: '2.0', - method: 'ui/notifications/sandbox-ready', + method: 'ui/notifications/sandbox-proxy-ready', params: {} }, '*'); })(); diff --git a/crates/goose/src/goose_apps/resource.rs b/crates/goose/src/goose_apps/resource.rs index 6afbdcc4b171..8236efb049b3 100644 --- a/crates/goose/src/goose_apps/resource.rs +++ b/crates/goose/src/goose_apps/resource.rs @@ -12,6 +12,12 @@ pub struct CspMetadata { /// Domains allowed for resource loading (scripts, styles, images, fonts, media) #[serde(skip_serializing_if = "Option::is_none")] pub resource_domains: Option>, + /// Domains allowed for frame-src (nested iframes) + #[serde(skip_serializing_if = "Option::is_none")] + pub frame_domains: Option>, + /// Domains allowed for base-uri + #[serde(skip_serializing_if = "Option::is_none")] + pub base_uri_domains: Option>, } /// Sandbox permissions for MCP Apps diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 61124bb272fe..ed23cbf6ce7a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3672,6 +3672,14 @@ "type": "object", "description": "Content Security Policy metadata for MCP Apps\nSpecifies allowed domains for network connections and resource loading", "properties": { + "baseUriDomains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Domains allowed for base-uri", + "nullable": true + }, "connectDomains": { "type": "array", "items": { @@ -3680,6 +3688,14 @@ "description": "Domains allowed for connect-src (fetch, XHR, WebSocket)", "nullable": true }, + "frameDomains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Domains allowed for frame-src (nested iframes)", + "nullable": true + }, "resourceDomains": { "type": "array", "items": { diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 2daf742f95cb..45000a274c53 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -9,7 +9,8 @@ "version": "1.23.0", "license": "Apache-2.0", "dependencies": { - "@mcp-ui/client": "^5.17.3", + "@mcp-ui/client": "^6.0.0", + "@modelcontextprotocol/ext-apps": "^1.0.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -3027,27 +3028,272 @@ } }, "node_modules/@mcp-ui/client": { - "version": "5.17.3", - "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-5.17.3.tgz", - "integrity": "sha512-Xxi8d5NYbCBUdBEqhwnm7PgJE6+F1wOV2sPA0AWnfLoudpiq8iomovL/AMFI+alVZwhSBxzqJKqUdbPD2yz5tQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-6.0.0.tgz", + "integrity": "sha512-dHIQGjFOoBWBntSRUJH5YFeq7xi2rEPS0EwokeNAnMg6xrjGjvNd6vTWDHFRC04OlO/ogvM1r5+xUoo0OETaaQ==", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.24.0", "@quilted/threads": "^3.1.3", "@r2wc/react-to-web-component": "^2.0.4", "@remote-dom/core": "^1.8.0", - "@remote-dom/react": "^1.2.2" + "@remote-dom/react": "^1.2.2", + "zod": "^3.23.8" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, + "node_modules/@mcp-ui/client/node_modules/@modelcontextprotocol/ext-apps": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.3.1.tgz", + "integrity": "sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@mcp-ui/client/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@mcp-ui/client/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@mcp-ui/client/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@mcp-ui/client/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@mcp-ui/client/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@mcp-ui/client/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.1.tgz", + "integrity": "sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==", + "hasInstallScript": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "optionalDependencies": { + "@oven/bun-darwin-aarch64": "^1.2.21", + "@oven/bun-darwin-x64": "^1.2.21", + "@oven/bun-darwin-x64-baseline": "^1.2.21", + "@oven/bun-linux-aarch64": "^1.2.21", + "@oven/bun-linux-aarch64-musl": "^1.2.21", + "@oven/bun-linux-x64": "^1.2.21", + "@oven/bun-linux-x64-baseline": "^1.2.21", + "@oven/bun-linux-x64-musl": "^1.2.21", + "@oven/bun-linux-x64-musl-baseline": "^1.2.21", + "@oven/bun-windows-x64": "^1.2.21", + "@oven/bun-windows-x64-baseline": "^1.2.21", + "@rollup/rollup-darwin-arm64": "^4.53.3", + "@rollup/rollup-darwin-x64": "^4.53.3", + "@rollup/rollup-linux-arm64-gnu": "^4.53.3", + "@rollup/rollup-linux-x64-gnu": "^4.53.3", + "@rollup/rollup-win32-arm64-msvc": "^4.53.3", + "@rollup/rollup-win32-x64-msvc": "^4.53.3" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/ext-apps/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.25.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -3214,6 +3460,149 @@ "node": ">=10" } }, + "node_modules/@oven/bun-darwin-aarch64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.8.tgz", + "integrity": "sha512-hPERz4IgXCM6Y6GdEEsJAFceyJMt29f3HlFzsvE/k+TQjChRhar6S+JggL35b9VmFfsdxyCOOTPqgnSrdV0etA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.8.tgz", + "integrity": "sha512-SaWIxsRQYiT/eA60bqA4l8iNO7cJ6YD8ie82RerRp9voceBxPIZiwX4y20cTKy5qNaSGr9LxfYq7vDywTipiog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-darwin-x64-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.8.tgz", + "integrity": "sha512-ArHVWpCRZI3vGLoN2/8ud8Kzqlgn1Gv+fNw+pMB9x18IzgAEhKxFxsWffnoaH21amam4tAOhpeewRIgdNtB0Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oven/bun-linux-aarch64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.8.tgz", + "integrity": "sha512-rq0nNckobtS+ONoB95/Frfqr8jCtmSjjjEZlN4oyUx0KEBV11Vj4v3cDVaWzuI34ryL8FCog3HaqjfKn8R82Tw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-aarch64-musl": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.8.tgz", + "integrity": "sha512-HvJmhrfipL7GtuqFz6xNpmf27NGcCOMwCalPjNR6fvkLpe8A7Z1+QbxKKjOglelmlmZc3Vi2TgDUtxSqfqOToQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.8.tgz", + "integrity": "sha512-YDgqVx1MI8E0oDbCEUSkAMBKKGnUKfaRtMdLh9Bjhu7JQacQ/ZCpxwi4HPf5Q0O1TbWRrdxGw2tA2Ytxkn7s1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.8.tgz", + "integrity": "sha512-3IkS3TuVOzMqPW6Gg9/8FEoKF/rpKZ9DZUfNy9GQ54+k4PGcXpptU3+dy8D4iDFCt4qe6bvoiAOdM44OOsZ+Wg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.8.tgz", + "integrity": "sha512-o7Jm5zL4aw9UBs3BcZLVbgGm2V4F10MzAQAV+ziKzoEfYmYtvDqRVxgKEq7BzUOVy4LgfrfwzEXw5gAQGRrhQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-linux-x64-musl-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.8.tgz", + "integrity": "sha512-5g8XJwHhcTh8SGoKO7pR54ILYDbuFkGo+68DOMTiVB5eLxuLET+Or/camHgk4QWp3nUS5kNjip4G8BE8i0rHVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oven/bun-windows-x64": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.8.tgz", + "integrity": "sha512-UDI3rowMm/tI6DIynpE4XqrOhr+1Ztk1NG707Wxv2nygup+anTswgCwjfjgmIe78LdoRNFrux2GpeolhQGW6vQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oven/bun-windows-x64-baseline": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.8.tgz", + "integrity": "sha512-K6qBUKAZLXsjAwFxGTG87dsWlDjyDl2fqjJr7+x7lmv2m+aSEzmLOK+Z5pSvGkpjBp3LXV35UUgj8G0UTd0pPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.16.2", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.2.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 3166488f49ec..26c263eee7ea 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -40,7 +40,8 @@ "start-alpha-gui": "ALPHA=true npm run start-gui" }, "dependencies": { - "@mcp-ui/client": "^5.17.3", + "@mcp-ui/client": "^6.0.0", + "@modelcontextprotocol/ext-apps": "^1.0.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 89566d5e1cdb..776953421a77 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -143,10 +143,18 @@ export type CreateScheduleRequest = { * Specifies allowed domains for network connections and resource loading */ export type CspMetadata = { + /** + * Domains allowed for base-uri + */ + baseUriDomains?: Array | null; /** * Domains allowed for connect-src (fetch, XHR, WebSocket) */ connectDomains?: Array | null; + /** + * Domains allowed for frame-src (nested iframes) + */ + frameDomains?: Array | null; /** * Domains allowed for resource loading (scripts, styles, images, fonts, media) */ diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 4f0b0553a1ad..61a6309873a8 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -1,50 +1,178 @@ -import { AppEvents } from '../../constants/events'; /** - * MCP Apps Renderer + * McpAppRenderer — Renders interactive MCP App UIs inside a sandboxed iframe. + * + * This component implements the host side of the MCP Apps protocol using the + * @mcp-ui/client SDK's AppRenderer. It handles resource fetching, sandbox + * proxy setup, CSP enforcement, and bidirectional communication with guest apps. * - * Temporary Goose implementation while waiting for official SDK components. + * Protocol references: + * - MCP Apps Extension (ext-apps): https://github.com/modelcontextprotocol/ext-apps + * - MCP-UI Client SDK: https://github.com/idosal/mcp-ui + * - App Bridge types: @modelcontextprotocol/ext-apps/app-bridge * - * @see SEP-1865 https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx + * Display modes: + * - "inline" | "fullscreen" | "pip" — standard MCP display modes + * - "standalone" — Goose-specific mode for dedicated Electron windows */ -import { useState, useCallback, useEffect } from 'react'; -import { useSandboxBridge } from './useSandboxBridge'; -import { - ToolInput, - ToolInputPartial, - ToolResult, - ToolCancelled, - CspMetadata, - PermissionsMetadata, - McpMethodParams, - McpMethodResponse, -} from './types'; +import { AppRenderer } from '@mcp-ui/client'; +import type { + McpUiDisplayMode, + McpUiHostContext, + McpUiResourceCsp, + McpUiResourcePermissions, + McpUiSizeChangedNotification, +} from '@modelcontextprotocol/ext-apps/app-bridge'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import { callTool, readResource } from '../../api'; +import { AppEvents } from '../../constants/events'; +import { useTheme } from '../../contexts/ThemeContext'; import { cn } from '../../utils'; -import { DEFAULT_IFRAME_HEIGHT } from './utils'; -import { readResource, callTool } from '../../api'; import { errorMessage } from '../../utils/conversionUtils'; -import { isProtocolSafe, getProtocol } from '../../utils/urlSecurity'; +import { getProtocol, isProtocolSafe } from '../../utils/urlSecurity'; +import FlyingBird from '../FlyingBird'; +import { + GooseDisplayMode, + SandboxPermissions, + McpAppToolCancelled, + McpAppToolInput, + McpAppToolInputPartial, + McpAppToolResult, +} from './types'; + +const DEFAULT_IFRAME_HEIGHT = 200; + +const AVAILABLE_DISPLAY_MODES: McpUiDisplayMode[] = ['inline']; + +async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise { + try { + const baseUrl = await window.electron.getGoosedHostPort(); + const secretKey = await window.electron.getSecretKey(); + + if (!baseUrl || !secretKey) { + console.error('[McpAppRenderer] Failed to get goosed host/port or secret key'); + return null; + } + + const params = new URLSearchParams(); + params.set('secret', secretKey); + + if (csp?.connectDomains?.length) { + params.set('connect_domains', csp.connectDomains.join(',')); + } + if (csp?.resourceDomains?.length) { + params.set('resource_domains', csp.resourceDomains.join(',')); + } + if (csp?.frameDomains?.length) { + params.set('frame_domains', csp.frameDomains.join(',')); + } + if (csp?.baseUriDomains?.length) { + params.set('base_uri_domains', csp.baseUriDomains.join(',')); + } + + return `${baseUrl}/mcp-app-proxy?${params.toString()}`; + } catch (error) { + console.error('[McpAppRenderer] Error fetching MCP App Proxy URL:', error); + return null; + } +} interface McpAppRendererProps { resourceUri: string; extensionName: string; sessionId?: string | null; - toolInput?: ToolInput; - toolInputPartial?: ToolInputPartial; - toolResult?: ToolResult; - toolCancelled?: ToolCancelled; + toolInput?: McpAppToolInput; + toolInputPartial?: McpAppToolInputPartial; + toolResult?: McpAppToolResult; + toolCancelled?: McpAppToolCancelled; append?: (text: string) => void; - fullscreen?: boolean; + displayMode?: GooseDisplayMode; cachedHtml?: string; } -interface ResourceData { - html: string | null; - csp: CspMetadata | null; - permissions: PermissionsMetadata | null; +interface ResourceMeta { + csp: McpUiResourceCsp | null; + permissions: SandboxPermissions | null; prefersBorder: boolean; } +const DEFAULT_META: ResourceMeta = { csp: null, permissions: null, prefersBorder: true }; + +// Lifecycle: idle → loading_resource → loading_sandbox → ready +// Any state can transition to error. The sandbox URL is fetched only once +// to prevent iframe recreation (which would cause the app to lose state). +type AppState = + | { status: 'idle' } + | { status: 'loading_resource'; html: string | null; meta: ResourceMeta } + | { status: 'loading_sandbox'; html: string; meta: ResourceMeta } + | { + status: 'ready'; + html: string; + meta: ResourceMeta; + sandboxUrl: URL; + sandboxCsp: McpUiResourceCsp | null; + } + | { status: 'error'; message: string; html: string | null; meta: ResourceMeta }; + +type AppAction = + | { type: 'FETCH_RESOURCE' } + | { type: 'RESOURCE_LOADED'; html: string | null; meta: ResourceMeta } + | { type: 'RESOURCE_FAILED'; message: string } + | { type: 'SANDBOX_READY'; sandboxUrl: string; sandboxCsp: McpUiResourceCsp | null } + | { type: 'SANDBOX_FAILED'; message: string } + | { type: 'ERROR'; message: string }; + +function getMeta(state: AppState): ResourceMeta { + return state.status === 'idle' ? DEFAULT_META : state.meta; +} + +function getHtml(state: AppState): string | null { + return state.status === 'idle' ? null : state.html; +} + +function appReducer(state: AppState, action: AppAction): AppState { + const meta = getMeta(state); + const html = getHtml(state); + + switch (action.type) { + case 'FETCH_RESOURCE': + return { status: 'loading_resource', html, meta }; + + case 'RESOURCE_LOADED': + if (!action.html) { + return { status: 'loading_resource', html: null, meta: action.meta }; + } + if (state.status === 'ready') { + return { ...state, html: action.html, meta: action.meta }; + } + return { status: 'loading_sandbox', html: action.html, meta: action.meta }; + + case 'RESOURCE_FAILED': + if (html) { + if (state.status === 'ready') return state; + return { status: 'loading_sandbox', html, meta }; + } + return { status: 'error', message: action.message, html: null, meta }; + + case 'SANDBOX_READY': + if (!html) return state; + return { + status: 'ready', + html, + meta, + sandboxUrl: new URL(action.sandboxUrl), + sandboxCsp: action.sandboxCsp, + }; + + case 'SANDBOX_FAILED': + return { status: 'error', message: action.message, html, meta }; + + case 'ERROR': + return { status: 'error', message: action.message, html, meta }; + } +} + export default function McpAppRenderer({ resourceUri, extensionName, @@ -54,25 +182,30 @@ export default function McpAppRenderer({ toolResult, toolCancelled, append, - fullscreen = false, + displayMode = 'inline', cachedHtml, }: McpAppRendererProps) { - const [resource, setResource] = useState({ - html: cachedHtml || null, - csp: null, - permissions: null, - prefersBorder: true, - }); - const [error, setError] = useState(null); + const isExpandedView = displayMode === 'fullscreen' || displayMode === 'standalone'; + + const { resolvedTheme } = useTheme(); + + const initialState: AppState = cachedHtml + ? { status: 'loading_sandbox', html: cachedHtml, meta: DEFAULT_META } + : { status: 'idle' }; + + const [state, dispatch] = useReducer(appReducer, initialState); const [iframeHeight, setIframeHeight] = useState(DEFAULT_IFRAME_HEIGHT); + // null = fluid (100% width), number = explicit width from app const [iframeWidth, setIframeWidth] = useState(null); + // Fetch the resource from the extension to get HTML and metadata (CSP, permissions, etc.). + // If cachedHtml is provided we show it immediately; the fetch updates metadata and + // replaces HTML only if the server returns different content. useEffect(() => { - if (!sessionId) { - return; - } + if (!sessionId) return; - const fetchResource = async () => { + const fetchResourceData = async () => { + dispatch({ type: 'FETCH_RESOURCE' }); try { const response = await readResource({ body: { @@ -84,243 +217,329 @@ export default function McpAppRenderer({ if (response.data) { const content = response.data; - const meta = content._meta as + const rawMeta = content._meta as | { ui?: { - csp?: CspMetadata; - permissions?: PermissionsMetadata; + csp?: McpUiResourceCsp; + permissions?: McpUiResourcePermissions; prefersBorder?: boolean; }; } | undefined; - if (content.text !== cachedHtml) { - setResource({ - html: content.text, - csp: meta?.ui?.csp || null, - permissions: meta?.ui?.permissions || null, - prefersBorder: meta?.ui?.prefersBorder ?? true, - }); - } + dispatch({ + type: 'RESOURCE_LOADED', + html: content.text ?? cachedHtml ?? null, + meta: { + csp: rawMeta?.ui?.csp || null, + // todo: pass permissions to SDK once it supports sendSandboxResourceReady + // https://github.com/MCP-UI-Org/mcp-ui/issues/180 + permissions: null, + prefersBorder: rawMeta?.ui?.prefersBorder ?? true, + }, + }); } } catch (err) { - if (!cachedHtml) { - setError(errorMessage(err, 'Failed to load resource')); - } else { + console.error('[McpAppRenderer] Error fetching resource:', err); + if (cachedHtml) { console.warn('Failed to fetch fresh resource, using cached version:', err); } + dispatch({ + type: 'RESOURCE_FAILED', + message: errorMessage(err, 'Failed to load resource'), + }); } }; - fetchResource(); + fetchResourceData(); }, [resourceUri, extensionName, sessionId, cachedHtml]); - const handleMcpRequest = useCallback( - async ( - method: string, - params: Record = {}, - _id?: string | number - ): Promise => { - // Methods that require a session - const requiresSession = ['tools/call', 'resources/read']; - if (requiresSession.includes(method) && !sessionId) { - throw new Error('Session not initialized for MCP request'); + // Create the sandbox proxy URL once we have HTML and metadata. + // Fetched only once — recreating the proxy would destroy iframe state. + const pendingCsp = state.status === 'loading_sandbox' ? state.meta.csp : null; + useEffect(() => { + if (state.status !== 'loading_sandbox') return; + + fetchMcpAppProxyUrl(pendingCsp).then((url) => { + if (url) { + dispatch({ type: 'SANDBOX_READY', sandboxUrl: url, sandboxCsp: pendingCsp }); + } else { + dispatch({ type: 'SANDBOX_FAILED', message: 'Failed to initialize sandbox proxy' }); } + }); + }, [state.status, pendingCsp]); - switch (method) { - case 'ui/open-link': { - const { url } = params as McpMethodParams['ui/open-link']; - - // Safe protocols open directly, unknown protocols require confirmation - // Dangerous protocols are blocked by main.ts in the open-external handler - if (isProtocolSafe(url)) { - await window.electron.openExternal(url); - } else { - const protocol = getProtocol(url); - if (!protocol) { - return { - status: 'error', - message: 'Invalid URL', - } as McpMethodResponse['ui/open-link']; - } - - const result = await window.electron.showMessageBox({ - type: 'question', - buttons: ['Cancel', 'Open'], - defaultId: 0, - title: 'Open External Link', - message: `Open ${protocol} link?`, - detail: `This will open: ${url}`, - }); - if (result.response !== 1) { - return { - status: 'error', - message: 'User cancelled', - } as McpMethodResponse['ui/open-link']; - } - await window.electron.openExternal(url); - } - - return { - status: 'success', - message: 'Link opened successfully', - } satisfies McpMethodResponse['ui/open-link']; - } + const handleOpenLink = useCallback(async ({ url }: { url: string }) => { + if (isProtocolSafe(url)) { + await window.electron.openExternal(url); + return { status: 'success' as const }; + } + + const protocol = getProtocol(url); + if (!protocol) { + return { status: 'error' as const, message: 'Invalid URL' }; + } - case 'ui/message': { - const { content } = params as McpMethodParams['ui/message']; - if (!append) { - throw new Error('Message handler not available in this context'); - } + const result = await window.electron.showMessageBox({ + type: 'question', + buttons: ['Cancel', 'Open'], + defaultId: 0, + title: 'Open External Link', + message: `Open ${protocol} link?`, + detail: `This will open: ${url}`, + }); - if (!Array.isArray(content)) { - throw new Error('Invalid message format: content must be an array of ContentBlock'); - } + if (result.response !== 1) { + return { status: 'error' as const, message: 'User cancelled' }; + } - // Extract first text block from content, ignoring other block types - const textContent = content.find((block) => block.type === 'text'); - if (!textContent) { - throw new Error('Invalid message format: content must contain a text block'); - } + await window.electron.openExternal(url); + return { status: 'success' as const }; + }, []); - // MCP Apps can send other content block types, but we only append text blocks for now + const handleMessage = useCallback( + async ({ content }: { content: Array<{ type: string; text?: string }> }) => { + if (!append) { + throw new Error('Message handler not available in this context'); + } + if (!Array.isArray(content)) { + throw new Error('Invalid message format: content must be an array of ContentBlock'); + } + const textContent = content.find((block) => block.type === 'text'); + if (!textContent || !textContent.text) { + throw new Error('Invalid message format: content must contain a text block'); + } + append(textContent.text); + window.dispatchEvent(new CustomEvent(AppEvents.SCROLL_CHAT_TO_BOTTOM)); + return {}; + }, + [append] + ); - append(textContent.text); - window.dispatchEvent(new CustomEvent(AppEvents.SCROLL_CHAT_TO_BOTTOM)); - return {} satisfies McpMethodResponse['ui/message']; - } + const handleCallTool = useCallback( + async ({ + name, + arguments: args, + }: { + name: string; + arguments?: Record; + }): Promise => { + if (!sessionId) { + throw new Error('Session not initialized for MCP request'); + } - case 'tools/call': { - const { name, arguments: args } = params as McpMethodParams['tools/call']; - const fullToolName = `${extensionName}__${name}`; - const response = await callTool({ - body: { - session_id: sessionId!, - name: fullToolName, - arguments: args || {}, - }, - }); - return { - content: response.data?.content || [], - isError: response.data?.is_error || false, - structuredContent: (response.data as Record)?.structured_content as - | Record - | undefined, - } satisfies McpMethodResponse['tools/call']; - } + const fullToolName = `${extensionName}__${name}`; + const response = await callTool({ + body: { + session_id: sessionId, + name: fullToolName, + arguments: args || {}, + }, + }); - case 'resources/read': { - const { uri } = params as McpMethodParams['resources/read']; - const response = await readResource({ - body: { - session_id: sessionId!, - uri, - extension_name: extensionName, - }, - }); - return { - contents: response.data ? [response.data] : [], - } satisfies McpMethodResponse['resources/read']; - } + // rmcp serializes Content with a `type` discriminator via #[serde(tag = "type")]. + // Our generated TS types don't reflect this, but the wire format matches CallToolResult.content. + return { + content: (response.data?.content || []) as unknown as CallToolResult['content'], + isError: response.data?.is_error || false, + structuredContent: response.data?.structured_content as + | { [key: string]: unknown } + | undefined, + }; + }, + [sessionId, extensionName] + ); - case 'notifications/message': { - const { level, logger, data } = params as McpMethodParams['notifications/message']; - console.log( - `[MCP App Notification]${logger ? ` [${logger}]` : ''} ${level || 'info'}:`, - data - ); - return {} satisfies McpMethodResponse['notifications/message']; - } + const handleReadResource = useCallback( + async ({ uri }: { uri: string }) => { + if (!sessionId) { + throw new Error('Session not initialized for MCP request'); + } + const response = await readResource({ + body: { + session_id: sessionId, + uri, + extension_name: extensionName, + }, + }); + const data = response.data; + if (!data) { + return { contents: [] }; + } + return { + contents: [{ uri: data.uri || uri, text: data.text, mimeType: data.mimeType || undefined }], + }; + }, + [sessionId, extensionName] + ); - case 'ping': - return {} satisfies McpMethodResponse['ping']; + const handleLoggingMessage = useCallback( + ({ level, logger, data }: { level?: string; logger?: string; data?: unknown }) => { + console.log( + `[MCP App Notification]${logger ? ` [${logger}]` : ''} ${level || 'info'}:`, + data + ); + }, + [] + ); - default: - throw new Error(`Unknown method: ${method}`); + /** + * Height: non-positive values are ignored (keeps previous height). + * Width: if provided, container uses that width (capped at 100%); + * if omitted or non-positive, container is fluid (100%). + */ + const handleSizeChanged = useCallback( + ({ height, width }: McpUiSizeChangedNotification['params']) => { + if (height !== undefined && height > 0) { + setIframeHeight(height); + } + if (width !== undefined) { + setIframeWidth(width > 0 ? width : null); } }, - [append, sessionId, extensionName] + [] ); - const handleSizeChanged = useCallback((height: number, width?: number) => { - const newHeight = Math.max(DEFAULT_IFRAME_HEIGHT, height); - setIframeHeight(newHeight); - setIframeWidth(width ?? null); + const handleError = useCallback((err: Error) => { + console.error('[MCP App Error]:', err); + dispatch({ type: 'ERROR', message: errorMessage(err) }); }, []); - const { iframeRef, proxyUrl } = useSandboxBridge({ - resourceHtml: resource.html || '', - resourceCsp: resource.csp, - resourcePermissions: resource.permissions, - resourceUri, - toolInput, - toolInputPartial, - toolResult, - toolCancelled, - onMcpRequest: handleMcpRequest, - onSizeChanged: handleSizeChanged, - }); - - if (error) { - return ( -
-
Failed to load MCP app: {error}
-
- ); - } + const meta = getMeta(state); + const html = getHtml(state); + + const readyCsp = state.status === 'ready' ? state.sandboxCsp : null; + const mcpUiCsp = useMemo((): McpUiResourceCsp | undefined => { + if (!readyCsp) return undefined; + return { + connectDomains: readyCsp.connectDomains ?? undefined, + resourceDomains: readyCsp.resourceDomains ?? undefined, + frameDomains: readyCsp.frameDomains ?? undefined, + baseUriDomains: readyCsp.baseUriDomains ?? undefined, + }; + }, [readyCsp]); + + const readySandboxUrl = state.status === 'ready' ? state.sandboxUrl : null; + const sandboxConfig = useMemo(() => { + if (!readySandboxUrl) return null; + return { + url: readySandboxUrl, + permissions: meta.permissions || 'allow-scripts allow-same-origin', + csp: mcpUiCsp, + }; + }, [readySandboxUrl, meta.permissions, mcpUiCsp]); + + const hostContext = useMemo((): McpUiHostContext => { + const context: McpUiHostContext = { + // todo: toolInfo: {} + theme: resolvedTheme, + // todo: styles: { variables: {}, styles: {} } + // 'standalone' is a Goose-specific display mode (dedicated Electron window) + // that maps to the spec's inline | fullscreen | pip modes. + displayMode: displayMode as McpUiDisplayMode, + availableDisplayModes: + displayMode === 'standalone' ? [displayMode as McpUiDisplayMode] : AVAILABLE_DISPLAY_MODES, + // todo: containerDimensions: {} (depends on displayMode) + locale: navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: navigator.userAgent, + platform: 'desktop', + deviceCapabilities: { + touch: navigator.maxTouchPoints > 0, + hover: window.matchMedia('(hover: hover)').matches, + }, + safeAreaInsets: { + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + }; + + return context; + }, [resolvedTheme, displayMode]); + + const appToolResult = useMemo((): CallToolResult | undefined => { + if (!toolResult) return undefined; + // rmcp serializes Content with a `type` discriminator via #[serde(tag = "type")]. + // Our generated TS types don't reflect this, but the wire format matches CallToolResult.content. + return { + content: toolResult.content as unknown as CallToolResult['content'], + structuredContent: toolResult.structuredContent as { [key: string]: unknown } | undefined, + }; + }, [toolResult]); + + const isToolCancelled = !!toolCancelled; + const isError = state.status === 'error'; + const isReady = state.status === 'ready'; - if (fullscreen) { - return proxyUrl ? ( -