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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user