feat(apps): Integrate AppRenderer from @mcp-ui/client SDK (#7013)

This commit is contained in:
Andrew Harvard
2026-02-12 11:04:50 -05:00
committed by GitHub
parent 5d939674be
commit a00c25c7b5
12 changed files with 928 additions and 720 deletions
@@ -138,7 +138,7 @@
.parent
.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-ready',
method: 'ui/notifications/sandbox-proxy-ready',
params: {}
}, '*');
})();
+6
View File
@@ -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<Vec<String>>,
/// Domains allowed for frame-src (nested iframes)
#[serde(skip_serializing_if = "Option::is_none")]
pub frame_domains: Option<Vec<String>>,
/// Domains allowed for base-uri
#[serde(skip_serializing_if = "Option::is_none")]
pub base_uri_domains: Option<Vec<String>>,
}
/// Sandbox permissions for MCP Apps
+16
View File
@@ -3751,6 +3751,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": {
@@ -3759,6 +3767,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": {
+395 -6
View File
@@ -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",
+2 -1
View File
@@ -39,7 +39,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",
+8
View File
@@ -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<string> | null;
/**
* Domains allowed for connect-src (fetch, XHR, WebSocket)
*/
connectDomains?: Array<string> | null;
/**
* Domains allowed for frame-src (nested iframes)
*/
frameDomains?: Array<string> | null;
/**
* Domains allowed for resource loading (scripts, styles, images, fonts, media)
*/
@@ -1,50 +1,178 @@
import { AppEvents } from '../../constants/events';
/**
* MCP Apps Renderer
* McpAppRenderer — Renders interactive MCP App UIs inside a sandboxed iframe.
*
* Temporary Goose implementation while waiting for official SDK components.
* 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.
*
* @see SEP-1865 https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx
* 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
*
* 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<string | null> {
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<ResourceData>({
html: cachedHtml || null,
csp: null,
permissions: null,
prefersBorder: true,
});
const [error, setError] = useState<string | null>(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<number | null>(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<string, unknown> = {},
_id?: string | number
): Promise<unknown> => {
// Methods that require a session
const requiresSession = ['tools/call', 'resources/read'];
if (requiresSession.includes(method) && !sessionId) {
// 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]);
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' };
}
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' as const, message: 'User cancelled' };
}
await window.electron.openExternal(url);
return { status: 'success' as const };
}, []);
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]
);
const handleCallTool = useCallback(
async ({
name,
arguments: args,
}: {
name: string;
arguments?: Record<string, unknown>;
}): Promise<CallToolResult> => {
if (!sessionId) {
throw new Error('Session not initialized for MCP request');
}
switch (method) {
case 'ui/open-link': {
const { url } = params as McpMethodParams['ui/open-link'];
const fullToolName = `${extensionName}__${name}`;
const response = await callTool({
body: {
session_id: sessionId,
name: fullToolName,
arguments: args || {},
},
});
// 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'];
}
case 'ui/message': {
const { content } = params as McpMethodParams['ui/message'];
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');
}
// 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');
}
// MCP Apps can send other content block types, but we only append text blocks for now
append(textContent.text);
window.dispatchEvent(new CustomEvent(AppEvents.SCROLL_CHAT_TO_BOTTOM));
return {} satisfies McpMethodResponse['ui/message'];
}
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<string, unknown>)?.structured_content as
| Record<string, unknown>
| undefined,
} satisfies McpMethodResponse['tools/call'];
}
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'];
}
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'];
}
case 'ping':
return {} satisfies McpMethodResponse['ping'];
default:
throw new Error(`Unknown method: ${method}`);
}
// 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,
};
},
[append, sessionId, extensionName]
[sessionId, extensionName]
);
const handleSizeChanged = useCallback((height: number, width?: number) => {
const newHeight = Math.max(DEFAULT_IFRAME_HEIGHT, height);
setIframeHeight(newHeight);
setIframeWidth(width ?? null);
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]
);
const handleLoggingMessage = useCallback(
({ level, logger, data }: { level?: string; logger?: string; data?: unknown }) => {
console.log(
`[MCP App Notification]${logger ? ` [${logger}]` : ''} ${level || 'info'}:`,
data
);
},
[]
);
/**
* 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);
}
},
[]
);
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,
});
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';
const renderContent = () => {
if (isError) {
return (
<div className="p-4 text-red-700 dark:text-red-300">
Failed to load MCP app: {state.message}
</div>
);
}
if (!isReady) {
return (
<div className="relative flex h-full w-full items-center justify-center overflow-hidden rounded bg-black/[0.03] dark:bg-white/[0.03]">
<div
className="absolute inset-0 animate-shimmer"
style={{
animationDuration: '2s',
background:
'linear-gradient(90deg, transparent 0%, rgba(128,128,128,0.08) 40%, rgba(128,128,128,0.12) 50%, rgba(128,128,128,0.08) 60%, transparent 100%)',
}}
/>
<FlyingBird className="relative z-10 scale-200 opacity-30" cycleInterval={120} />
</div>
);
}
if (!sandboxConfig) return null;
if (error) {
return (
<div className="p-4 border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-red-700 dark:text-red-300">Failed to load MCP app: {error}</div>
</div>
);
}
if (fullscreen) {
return proxyUrl ? (
<iframe
ref={iframeRef}
src={proxyUrl}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
sandbox="allow-scripts allow-same-origin"
<AppRenderer
sandbox={sandboxConfig}
toolName={resourceUri}
html={html ?? undefined}
toolInput={toolInput?.arguments}
toolInputPartial={toolInputPartial ? { arguments: toolInputPartial.arguments } : undefined}
toolCancelled={isToolCancelled}
hostContext={hostContext}
toolResult={appToolResult}
onOpenLink={handleOpenLink}
onMessage={handleMessage}
onCallTool={handleCallTool}
onReadResource={handleReadResource}
onLoggingMessage={handleLoggingMessage}
onSizeChanged={handleSizeChanged}
onError={handleError}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Loading...
</div>
);
}
};
const containerClasses = cn(
'bg-background-default overflow-hidden',
iframeWidth === null && '[&_iframe]:!w-full',
isError && 'border border-red-500 rounded-lg bg-red-50 dark:bg-red-900/20',
!isError && !isExpandedView && 'mt-6 mb-2',
!isError && !isExpandedView && meta.prefersBorder && 'border border-border-default rounded-lg'
);
const containerStyle = isExpandedView
? { width: '100%', height: '100%' }
: {
width: iframeWidth !== null ? `${iframeWidth}px` : '100%',
maxWidth: '100%',
height: `${iframeHeight || DEFAULT_IFRAME_HEIGHT}px`,
};
return (
<div
className={cn(
'bg-background-default overflow-hidden',
resource.prefersBorder ? 'border border-border-default rounded-lg' : 'my-6'
)}
>
{resource.html && proxyUrl ? (
<iframe
ref={iframeRef}
src={proxyUrl}
style={{
width: iframeWidth ? `${iframeWidth}px` : '100%',
maxWidth: '100%',
height: `${iframeHeight}px`,
border: 'none',
overflow: 'hidden',
}}
sandbox="allow-scripts allow-same-origin"
/>
) : (
<div className="flex items-center justify-center p-4" style={{ minHeight: '200px' }}>
Loading MCP app...
</div>
)}
<div className={containerClasses} style={containerStyle}>
{renderContent()}
</div>
);
}
+27 -104
View File
@@ -1,107 +1,30 @@
export type {
CspMetadata,
PermissionsMetadata,
CallToolResponse as ToolResult,
} from '../../api/types.gen';
import type {
McpUiToolInputNotification,
McpUiToolInputPartialNotification,
McpUiToolCancelledNotification,
McpUiDisplayMode,
} from '@modelcontextprotocol/ext-apps/app-bridge';
import type { Content } from '../../api';
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| {
type: 'resource';
resource: { uri: string; mimeType?: string; text?: string; blob?: string };
};
/**
* Space-separated sandbox tokens for iframe permissions.
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox
*/
export type SandboxPermissions = string;
export type McpMethodParams = {
'ui/open-link': { url: string };
'ui/message': { role: 'user'; content: ContentBlock[] };
'tools/call': { name: string; arguments?: Record<string, unknown> };
'resources/read': { uri: string };
'notifications/message': { level?: string; logger?: string; data: unknown };
ping: Record<string, never>;
export type GooseDisplayMode = McpUiDisplayMode | 'standalone';
/**
* Tool input from the message stream.
* McpAppRenderer extracts `.arguments` when passing to the SDK's AppRenderer.
*/
export type McpAppToolInput = McpUiToolInputNotification['params'];
export type McpAppToolInputPartial = McpUiToolInputPartialNotification['params'];
export type McpAppToolCancelled = McpUiToolCancelledNotification['params'];
export type McpAppToolResult = {
content: Content[];
structuredContent?: unknown;
};
export type McpMethodResponse = {
'ui/open-link': { status: string; message: string };
'ui/message': Record<string, never>;
'tools/call': {
content: unknown[];
isError: boolean;
structuredContent?: Record<string, unknown>;
};
'resources/read': { contents: unknown[] };
'notifications/message': Record<string, never>;
ping: Record<string, never>;
};
export interface JsonRpcRequest {
jsonrpc: '2.0';
id?: string | number;
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
export interface HostContext {
toolInfo?: {
id?: string | number;
tool: {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
};
};
theme: 'light' | 'dark';
displayMode: 'inline' | 'fullscreen' | 'standalone';
availableDisplayModes: ('inline' | 'fullscreen' | 'standalone')[];
viewport: {
width: number;
height: number;
maxHeight: number;
maxWidth: number;
};
locale: string;
timeZone: string;
userAgent: string;
platform: 'web' | 'desktop' | 'mobile';
deviceCapabilities: {
touch: boolean;
hover: boolean;
};
safeAreaInsets: {
top: number;
right: number;
bottom: number;
left: number;
};
}
export interface ToolInput {
arguments: Record<string, unknown>;
}
export interface ToolInputPartial {
arguments: Record<string, unknown>;
}
export interface ToolCancelled {
reason?: string;
}
@@ -1,300 +0,0 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import type {
JsonRpcMessage,
JsonRpcRequest,
JsonRpcNotification,
ToolInput,
ToolInputPartial,
ToolResult,
ToolCancelled,
HostContext,
CspMetadata,
PermissionsMetadata,
} from './types';
import { fetchMcpAppProxyUrl } from './utils';
import { useTheme } from '../../contexts/ThemeContext';
import packageJson from '../../../package.json';
import { errorMessage } from '../../utils/conversionUtils';
interface SandboxBridgeOptions {
resourceHtml: string;
resourceCsp: CspMetadata | null;
resourcePermissions: PermissionsMetadata | null;
resourceUri: string;
toolInput?: ToolInput;
toolInputPartial?: ToolInputPartial;
toolResult?: ToolResult;
toolCancelled?: ToolCancelled;
onMcpRequest: (
method: string,
params?: Record<string, unknown>,
id?: string | number
) => Promise<unknown>;
onSizeChanged?: (height: number, width?: number) => void;
}
interface SandboxBridgeResult {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
proxyUrl: string | null;
}
export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeResult {
const {
resourceHtml,
resourceCsp,
resourcePermissions,
resourceUri,
toolInput,
toolInputPartial,
toolResult,
toolCancelled,
onMcpRequest,
onSizeChanged,
} = options;
const { resolvedTheme } = useTheme();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const isGuestInitializedRef = useRef(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
useEffect(() => {
fetchMcpAppProxyUrl(resourceCsp).then(setProxyUrl);
}, [resourceCsp]);
// Reset initialization state when resource changes
useEffect(() => {
isGuestInitializedRef.current = false;
}, [resourceUri]);
const sendToSandbox = useCallback((message: JsonRpcMessage) => {
iframeRef.current?.contentWindow?.postMessage(message, '*');
}, []);
const handleJsonRpcMessage = useCallback(
async (data: unknown) => {
if (!data || typeof data !== 'object') return;
// Handle notifications (no id)
if ('method' in data && !('id' in data)) {
const msg = data as JsonRpcNotification;
switch (msg.method) {
case 'ui/notifications/sandbox-ready':
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-resource-ready',
params: { html: resourceHtml, csp: resourceCsp, permissions: resourcePermissions },
});
break;
case 'ui/notifications/initialized':
isGuestInitializedRef.current = true;
// Send any pending tool data that arrived before initialization
if (toolInput) {
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: { arguments: toolInput.arguments },
});
}
if (toolResult) {
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: toolResult,
});
}
break;
case 'ui/notifications/size-changed': {
const params = msg.params as { height: number; width?: number };
onSizeChanged?.(params.height, params.width);
break;
}
}
return;
}
// Handle requests (with id)
if ('method' in data && 'id' in data) {
const msg = data as JsonRpcRequest;
try {
if (msg.method === 'ui/initialize') {
if (msg.id === undefined) return;
const iframe = iframeRef.current;
const hostContext: HostContext = {
toolInfo: undefined,
theme: resolvedTheme,
displayMode: 'inline',
availableDisplayModes: ['inline'],
viewport: {
width: iframe?.clientWidth ?? 0,
height: iframe?.clientHeight ?? 0,
maxWidth: window.innerWidth,
maxHeight: window.innerHeight,
},
locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
userAgent: navigator.userAgent,
platform: 'desktop',
deviceCapabilities: {
touch: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
hover: window.matchMedia('(hover: hover)').matches,
},
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
};
sendToSandbox({
jsonrpc: '2.0',
id: msg.id,
result: {
protocolVersion: '2025-06-18',
hostCapabilities: { links: true, messages: true },
hostInfo: {
name: packageJson.productName,
version: packageJson.version,
},
hostContext,
},
});
return;
}
const result = await onMcpRequest(msg.method, msg.params, msg.id);
if (msg.id !== undefined) {
sendToSandbox({ jsonrpc: '2.0', id: msg.id, result });
}
} catch (error) {
console.error(`[Sandbox Bridge] Error handling ${msg.method}:`, error);
if (msg.id !== undefined) {
sendToSandbox({
jsonrpc: '2.0',
id: msg.id,
error: {
code: -32603,
message: errorMessage(error),
},
});
}
}
}
},
[
resourceHtml,
resourceCsp,
resourcePermissions,
resolvedTheme,
sendToSandbox,
onMcpRequest,
onSizeChanged,
toolInput,
toolResult,
]
);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
handleJsonRpcMessage(event.data);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [handleJsonRpcMessage]);
// Send tool input notification when it changes
useEffect(() => {
if (!isGuestInitializedRef.current || !toolInput) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input',
params: { arguments: toolInput.arguments },
});
}, [toolInput, sendToSandbox]);
useEffect(() => {
if (!isGuestInitializedRef.current || !toolInputPartial) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-input-partial',
params: { arguments: toolInputPartial.arguments },
});
}, [toolInputPartial, sendToSandbox]);
useEffect(() => {
if (!isGuestInitializedRef.current || !toolResult) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: toolResult,
});
}, [toolResult, sendToSandbox]);
useEffect(() => {
if (!isGuestInitializedRef.current || !toolCancelled) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/tool-cancelled',
params: toolCancelled.reason ? { reason: toolCancelled.reason } : {},
});
}, [toolCancelled, sendToSandbox]);
useEffect(() => {
if (!isGuestInitializedRef.current) return;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/host-context-changed',
params: { theme: resolvedTheme },
});
}, [resolvedTheme, sendToSandbox]);
useEffect(() => {
if (!isGuestInitializedRef.current || !iframeRef.current) return;
const iframe = iframeRef.current;
let lastWidth = iframe.clientWidth;
let lastHeight = iframe.clientHeight;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
const w = Math.round(width);
const h = Math.round(height);
if (w !== lastWidth || h !== lastHeight) {
lastWidth = w;
lastHeight = h;
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/host-context-changed',
params: {
viewport: {
width: w,
height: h,
maxWidth: window.innerWidth,
maxHeight: window.innerHeight,
},
},
});
}
});
observer.observe(iframe);
return () => observer.disconnect();
}, [sendToSandbox]);
useEffect(() => {
return () => {
if (isGuestInitializedRef.current) {
sendToSandbox({
jsonrpc: '2.0',
id: Date.now(),
method: 'ui/resource-teardown',
params: { reason: 'Component unmounting' },
});
}
};
}, [sendToSandbox]);
return { iframeRef, proxyUrl };
}
@@ -1,45 +0,0 @@
export const DEFAULT_IFRAME_HEIGHT = 200;
/**
* Fetch the MCP App proxy URL from the Electron backend.
* The proxy enforces CSP as a security boundary for sandboxed apps.
* TODO(Douwe): make this work better with the generated API rather than poking around
*/
export async function fetchMcpAppProxyUrl(
csp?: {
connectDomains?: string[] | null;
resourceDomains?: string[] | null;
frameDomains?: string[] | null;
baseUriDomains?: string[] | null;
} | null
): Promise<string | null> {
try {
const baseUrl = await window.electron.getGoosedHostPort();
const secretKey = await window.electron.getSecretKey();
if (!baseUrl || !secretKey) {
console.error('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('Error fetching MCP App Proxy URL:', error);
return null;
}
}
@@ -1,7 +1,7 @@
import { AppEvents } from '../constants/events';
import { ToolIconWithStatus, ToolCallStatus } from './ToolCallStatusIndicator';
import { getToolCallIcon } from '../utils/toolIconMapping';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent';
@@ -116,16 +116,13 @@ function McpAppWrapper({
? requestWithMeta.toolCall.value.arguments
: undefined;
const toolInput = useMemo(() => ({ arguments: toolArguments || {} }), [toolArguments]);
const toolInput = { arguments: toolArguments || {} };
const toolResult = useMemo(() => {
if (!toolResponse) return undefined;
const resultWithMeta = toolResponse.toolResult as ToolResultWithMeta;
if (resultWithMeta?.status === 'success' && resultWithMeta.value) {
return resultWithMeta.value;
}
return undefined;
}, [toolResponse]);
const resultWithMeta = toolResponse?.toolResult as ToolResultWithMeta | undefined;
const toolResult =
resultWithMeta?.status === 'success' && resultWithMeta.value
? resultWithMeta.value
: undefined;
if (!resourceUri) return null;
if (requestWithMeta.toolCall.status !== 'success') return null;
@@ -140,12 +137,6 @@ function McpAppWrapper({
sessionId={sessionId}
append={append}
/>
<div className="mt-3 p-4 py-3 border border-border-default rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-sans">
MCP Apps are experimental and may change at any time.
</div>
</div>
</div>
);
}
@@ -151,7 +151,7 @@ export default function StandaloneAppView() {
resourceUri={resourceUri!}
extensionName={extensionName!}
sessionId={sessionId || null}
fullscreen={true}
displayMode="standalone"
cachedHtml={cachedHtml || undefined}
/>
</div>