feat: add mcp app renderer (#6095)

Co-authored-by: Douwe Osinga <douwe@block.xyz>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andrew Harvard
2025-12-22 18:01:21 -05:00
committed by GitHub
parent 762b2cf54d
commit 91a6b21f39
29 changed files with 1786 additions and 95 deletions
+4 -1
View File
@@ -10,7 +10,10 @@ pub async fn check_token(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" {
if request.uri().path() == "/status"
|| request.uri().path() == "/mcp-ui-proxy"
|| request.uri().path() == "/mcp-app-proxy"
{
return Ok(next.run(request).await);
}
let secret_key = request
+4
View File
@@ -535,6 +535,10 @@ derive_utoipa!(Icon as IconSchema);
super::tunnel::TunnelInfo,
super::tunnel::TunnelState,
super::routes::telemetry::TelemetryEventRequest,
goose::goose_apps::McpAppResource,
goose::goose_apps::CspMetadata,
goose::goose_apps::UiMetadata,
goose::goose_apps::ResourceMetadata,
))
)]
pub struct ApiDoc;
+3
View File
@@ -116,6 +116,8 @@ pub struct CallToolResponse {
content: Vec<Content>,
structured_content: Option<Value>,
is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
_meta: Option<Value>,
}
#[utoipa::path(
@@ -681,6 +683,7 @@ async fn call_tool(
content: result.content,
structured_content: result.structured_content,
is_error: result.is_error.unwrap_or(false),
_meta: None,
}))
}
@@ -0,0 +1,118 @@
use axum::{
extract::Query,
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct ProxyQuery {
secret: String,
/// Comma-separated list of domains for connect-src (fetch, XHR, WebSocket)
connect_domains: Option<String>,
/// Comma-separated list of domains for resource loading (scripts, styles, images, fonts, media)
resource_domains: Option<String>,
}
const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html");
/// Build the outer sandbox CSP based on declared domains.
///
/// This CSP acts as a ceiling - the inner guest UI iframe cannot exceed these
/// permissions, even if it tried. This is the single source of truth for
/// security policy enforcement.
///
/// Based on the MCP Apps specification (ext-apps SEP):
/// <https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx>
fn build_outer_csp(connect_domains: &[String], resource_domains: &[String]) -> String {
let resources = if resource_domains.is_empty() {
String::new()
} else {
format!(" {}", resource_domains.join(" "))
};
let connections = if connect_domains.is_empty() {
String::new()
} else {
format!(" {}", connect_domains.join(" "))
};
format!(
"default-src 'none'; \
script-src 'self' 'unsafe-inline'{resources}; \
script-src-elem 'self' 'unsafe-inline'{resources}; \
style-src 'self' 'unsafe-inline'{resources}; \
style-src-elem 'self' 'unsafe-inline'{resources}; \
connect-src 'self'{connections}; \
img-src 'self' data: blob:{resources}; \
font-src 'self'{resources}; \
media-src 'self' data: blob:{resources}; \
frame-src blob: data:; \
object-src 'none'; \
base-uri 'self'"
)
}
/// Parse comma-separated domains, filtering out empty strings
fn parse_domains(domains: Option<&String>) -> Vec<String> {
domains
.map(|d| {
d.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
#[utoipa::path(
get,
path = "/mcp-app-proxy",
params(
("secret" = String, Query, description = "Secret key for authentication"),
("connect_domains" = Option<String>, Query, description = "Comma-separated domains for connect-src"),
("resource_domains" = Option<String>, Query, description = "Comma-separated domains for resource loading")
),
responses(
(status = 200, description = "MCP App proxy HTML page", content_type = "text/html"),
(status = 401, description = "Unauthorized - invalid or missing secret"),
)
)]
async fn mcp_app_proxy(
axum::extract::State(secret_key): axum::extract::State<String>,
Query(params): Query<ProxyQuery>,
) -> Response {
if params.secret != secret_key {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
// Parse domains from query params
let connect_domains = parse_domains(params.connect_domains.as_ref());
let resource_domains = parse_domains(params.resource_domains.as_ref());
// Build the outer CSP based on declared domains
let csp = build_outer_csp(&connect_domains, &resource_domains);
// Replace the CSP placeholder in the HTML template
let html = MCP_APP_PROXY_HTML.replace("{{OUTER_CSP}}", &csp);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(
header::HeaderName::from_static("referrer-policy"),
"no-referrer",
),
],
Html(html),
)
.into_response()
}
pub fn routes(secret_key: String) -> Router {
Router::new()
.route("/mcp-app-proxy", get(mcp_app_proxy))
.with_state(secret_key)
}
+3 -1
View File
@@ -3,6 +3,7 @@ pub mod agent;
pub mod audio;
pub mod config_management;
pub mod errors;
pub mod mcp_app_proxy;
pub mod mcp_ui_proxy;
pub mod recipe;
pub mod recipe_utils;
@@ -34,5 +35,6 @@ pub fn configure(state: Arc<crate::state::AppState>, secret_key: String) -> Rout
.merge(setup::routes(state.clone()))
.merge(telemetry::routes(state.clone()))
.merge(tunnel::routes(state.clone()))
.merge(mcp_ui_proxy::routes(secret_key))
.merge(mcp_ui_proxy::routes(secret_key.clone()))
.merge(mcp_app_proxy::routes(secret_key))
}
@@ -0,0 +1,135 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="referrer" content="no-referrer"/>
<!--
The Content Security Policy is dynamically created by Rust at request time based on the domains declared in the MCP App's resource metadata.
-->
<meta
http-equiv="Content-Security-Policy"
content="{{OUTER_CSP}}"/>
<title>MCP App Sandbox</title>
<style>
body,
html {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<script>
(function () {
'use strict';
let guestIframe = null;
function createGuestIframe(html) {
if (guestIframe) {
guestIframe.remove();
}
guestIframe = document.createElement('iframe');
// Sandbox permissions for the Guest UI
// allow-scripts: needed for the app to run
// allow-same-origin: needed for localStorage, cookies, etc.
// allow-forms: needed if the app has forms
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
guestIframe.srcdoc = html;
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
document
.body
.appendChild(guestIframe);
}
/**
* Handle messages from the Host (parent window).
*/
function handleHostMessage(event) {
if (event.source !== window.parent) {
return;
}
var data = event.data;
if (!data || typeof data !== 'object') {
return;
}
var method = data.method;
// Handle sandbox-specific notifications from Host
if (method === 'ui/notifications/sandbox-resource-ready') {
var params = data.params || {};
var html = params.html || '';
createGuestIframe(html);
return;
}
// Forward all other messages to Guest UI (if it exists)
// This includes lifecycle messages like responses to ui/initialize
if (guestIframe && guestIframe.contentWindow) {
guestIframe
.contentWindow
.postMessage(data, '*');
}
}
/**
* Handle messages from the Guest UI (inner iframe).
*/
function handleGuestMessage(event) {
if (!guestIframe || event.source !== guestIframe.contentWindow) {
return;
}
var data = event.data;
if (!data || typeof data !== 'object') {
return;
}
// Forward all messages from Guest UI to Host
// The Sandbox does NOT create its own requests - just relays
window
.parent
.postMessage(data, '*');
}
/**
* Main message handler - routes to appropriate handler.
*/
function handleMessage(event) {
if (event.source === window.parent) {
handleHostMessage(event);
} else if (guestIframe && event.source === guestIframe.contentWindow) {
handleGuestMessage(event);
}
}
// Set up message listener
window.addEventListener('message', handleMessage);
// Notify Host that Sandbox is ready
window
.parent
.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-ready',
params: {}
}, '*');
})();
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
//! goose Apps module
//!
//! This module contains types and utilities for working with goose Apps,
//! which are UI resources that can be rendered in an MCP server or native
//! goose apps, or something in between.
pub mod resource;
pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata};
+112
View File
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// Content Security Policy metadata for MCP Apps
/// Specifies allowed domains for network connections and resource loading
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CspMetadata {
/// Domains allowed for connect-src (fetch, XHR, WebSocket)
#[serde(skip_serializing_if = "Option::is_none")]
pub connect_domains: Option<Vec<String>>,
/// Domains allowed for resource loading (scripts, styles, images, fonts, media)
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_domains: Option<Vec<String>>,
}
/// UI-specific metadata for MCP resources
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UiMetadata {
/// Content Security Policy configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub csp: Option<CspMetadata>,
/// Preferred domain for the app (used for CORS)
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
/// Whether the app prefers to have a border around it
#[serde(skip_serializing_if = "Option::is_none")]
pub prefers_border: Option<bool>,
}
/// Resource metadata containing UI configuration
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResourceMetadata {
/// UI-specific configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub ui: Option<UiMetadata>,
}
/// MCP App Resource
/// Represents a UI resource that can be rendered in an MCP App
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct McpAppResource {
/// URI of the resource (must use ui:// scheme)
pub uri: String,
/// Human-readable name of the resource
pub name: String,
/// Optional description of what this resource does
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// MIME type (should be "text/html;profile=mcp-app" for MCP Apps)
pub mime_type: String,
/// Text content of the resource (HTML for MCP Apps)
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// Base64-encoded binary content (alternative to text)
#[serde(skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Resource metadata including UI configuration
#[serde(skip_serializing_if = "Option::is_none", rename = "_meta")]
pub meta: Option<ResourceMetadata>,
}
impl McpAppResource {
pub fn new_html(uri: String, name: String, html: String) -> Self {
Self {
uri,
name,
description: None,
mime_type: "text/html;profile=mcp-app".to_string(),
text: Some(html),
blob: None,
meta: None,
}
}
pub fn new_html_with_csp(uri: String, name: String, html: String, csp: CspMetadata) -> Self {
Self {
uri,
name,
description: None,
mime_type: "text/html;profile=mcp-app".to_string(),
text: Some(html),
blob: None,
meta: Some(ResourceMetadata {
ui: Some(UiMetadata {
csp: Some(csp),
domain: None,
prefers_border: None,
}),
}),
}
}
pub fn with_description(mut self, description: String) -> Self {
self.description = Some(description);
self
}
pub fn with_ui_metadata(mut self, ui_metadata: UiMetadata) -> Self {
if let Some(meta) = &mut self.meta {
meta.ui = Some(ui_metadata);
} else {
self.meta = Some(ResourceMetadata {
ui: Some(ui_metadata),
});
}
self
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod config;
pub mod context_mgmt;
pub mod conversation;
pub mod execution;
pub mod goose_apps;
pub mod hints;
pub mod logging;
pub mod mcp_utils;