render mcp apps inline in goose2 (#8877)

Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
Andrew Harvard
2026-05-04 08:25:38 -04:00
committed by GitHub
parent 4f7a2e8a52
commit b11c9a3d98
50 changed files with 3403 additions and 78 deletions
+22 -2
View File
@@ -36,6 +36,17 @@ use std::io::Read;
use std::path::PathBuf;
use tracing::warn;
const GOOSE_SERVER_SECRET_KEY_ENV: &str = "GOOSE_SERVER__SECRET_KEY";
fn generate_serve_secret_key() -> String {
use rand::distributions::{Alphanumeric, DistString};
format!(
"goose-acp-{}",
Alphanumeric.sample_string(&mut rand::thread_rng(), 32)
)
}
#[derive(Parser)]
#[command(name = "goose", author, version, display_name = "", about, long_about = None)]
pub struct Cli {
@@ -1086,13 +1097,22 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec<String>) ->
config_dir: Paths::config_dir(),
goose_platform: GoosePlatform::GooseCli,
}));
let router = create_router(server);
let secret_key = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV)
.ok()
.map(|secret| secret.trim().to_string())
.filter(|secret| !secret.is_empty())
.unwrap_or_else(generate_serve_secret_key);
let router = create_router(server, secret_key);
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
info!("Starting ACP server on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
+25
View File
@@ -74,6 +74,31 @@ pub struct ReadResourceResponse {
pub result: serde_json::Value,
}
/// Call a tool from an extension.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/tool/call", response = GooseToolCallResponse)]
#[serde(rename_all = "camelCase")]
pub struct GooseToolCallRequest {
pub session_id: String,
pub name: String,
#[serde(default)]
pub arguments: serde_json::Value,
}
/// Tool call response.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
pub struct GooseToolCallResponse {
#[serde(default)]
pub content: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<serde_json::Value>,
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_meta")]
pub meta: Option<serde_json::Value>,
}
/// Update the working directory for a session.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/working_dir/update", response = EmptyResponse)]
+5
View File
@@ -15,6 +15,11 @@
"requestType": "GetToolsRequest",
"responseType": "GetToolsResponse"
},
{
"method": "_goose/tool/call",
"requestType": "GooseToolCallRequest",
"responseType": "GooseToolCallResponse"
},
{
"method": "_goose/resource/read",
"requestType": "ReadResourceRequest",
+59
View File
@@ -73,6 +73,48 @@
"x-side": "agent",
"x-method": "_goose/tools"
},
"GooseToolCallRequest": {
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"name": {
"type": "string"
},
"arguments": {
"default": null
}
},
"required": [
"sessionId",
"name"
],
"description": "Call a tool from an extension.",
"x-side": "agent",
"x-method": "_goose/tool/call"
},
"GooseToolCallResponse": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {},
"default": []
},
"structuredContent": {},
"isError": {
"type": "boolean"
},
"_meta": {}
},
"required": [
"isError"
],
"description": "Tool call response.",
"x-side": "agent",
"x-method": "_goose/tool/call"
},
"ReadResourceRequest": {
"type": "object",
"properties": {
@@ -2096,6 +2138,15 @@
"description": "Params for _goose/tools",
"title": "GetToolsRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/GooseToolCallRequest"
}
],
"description": "Params for _goose/tool/call",
"title": "GooseToolCallRequest"
},
{
"allOf": [
{
@@ -2556,6 +2607,14 @@
],
"title": "GetToolsResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/GooseToolCallResponse"
}
],
"title": "GooseToolCallResponse"
},
{
"allOf": [
{
+459
View File
@@ -0,0 +1,459 @@
use axum::{
extract::{ConnectInfo, Query, State},
http::{header, HeaderValue, StatusCode},
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::RwLock;
use uuid::Uuid;
const GUEST_HTML_TTL_SECS: u64 = 300;
const GUEST_HTML_MAX_ENTRIES: usize = 64;
const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html");
type GuestHtmlStore = Arc<RwLock<HashMap<String, GuestHtmlEntry>>>;
#[derive(Clone)]
struct GuestHtmlEntry {
html: String,
csp: String,
created: Instant,
}
#[derive(Deserialize)]
struct ProxyQuery {
secret: String,
connect_domains: Option<String>,
resource_domains: Option<String>,
frame_domains: Option<String>,
base_uri_domains: Option<String>,
script_domains: Option<String>,
}
#[derive(Deserialize)]
struct GuestQuery {
nonce: String,
}
#[derive(Deserialize)]
struct StoreGuestBody {
secret: String,
html: String,
csp: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StoreGuestResponse {
nonce: String,
guest_url: String,
}
#[derive(Clone)]
struct AppState {
secret_key: String,
guest_store: GuestHtmlStore,
guest_base_url: String,
}
#[derive(Clone)]
struct GuestState {
guest_store: GuestHtmlStore,
}
fn normalize_csp_source(source: &str) -> Option<String> {
let source = source.trim();
if source.is_empty()
|| source
.chars()
.any(|c| c.is_ascii_whitespace() || matches!(c, ';' | ',' | '"' | '\''))
{
return None;
}
if let Some((scheme, rest)) = source.split_once("://") {
let scheme = scheme.to_ascii_lowercase();
if !matches!(scheme.as_str(), "http" | "https" | "ws" | "wss") {
return None;
}
let authority = rest.split(['/', '?', '#']).next()?;
if !is_valid_csp_host_source(authority) {
return None;
}
return Some(format!("{scheme}://{}", authority.to_ascii_lowercase()));
}
if is_valid_csp_host_source(source) {
return Some(source.to_ascii_lowercase());
}
None
}
fn is_valid_csp_host_source(source: &str) -> bool {
if source.is_empty() || source == "*" || source.contains('@') {
return false;
}
let (host, port) = split_host_and_port(source);
if host.is_empty() {
return false;
}
if port.is_some_and(|port| port.is_empty() || port.parse::<u16>().is_err()) {
return false;
}
let host = host.strip_prefix("*.").unwrap_or(host);
if host.eq_ignore_ascii_case("localhost")
|| host.parse::<std::net::Ipv4Addr>().is_ok()
|| host.parse::<std::net::Ipv6Addr>().is_ok()
{
return true;
}
!host.is_empty()
&& host.contains('.')
&& host
.split('.')
.all(|label| is_valid_dns_label(label) && label != "*")
}
fn split_host_and_port(source: &str) -> (&str, Option<&str>) {
if let Some(remainder) = source.strip_prefix('[') {
if let Some((host, tail)) = remainder.split_once(']') {
let port = tail.strip_prefix(':');
return (host, port);
}
}
match source.rsplit_once(':') {
Some((host, port)) if !host.contains(':') => (host, Some(port)),
_ => (source, None),
}
}
fn is_valid_dns_label(label: &str) -> bool {
!label.is_empty()
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
fn peer_addr_is_loopback(peer_addr: &SocketAddr) -> bool {
peer_addr.ip().is_loopback()
}
fn parse_domains(domains: Option<&String>) -> Vec<String> {
domains
.map(|domains| {
domains
.split(',')
.filter_map(normalize_csp_source)
.collect()
})
.unwrap_or_default()
}
fn build_outer_csp(
connect_domains: &[String],
resource_domains: &[String],
frame_domains: &[String],
base_uri_domains: &[String],
script_domains: &[String],
guest_origin: &str,
) -> String {
let resources = if resource_domains.is_empty() {
String::new()
} else {
format!(" {}", resource_domains.join(" "))
};
let scripts = if script_domains.is_empty() {
String::new()
} else {
format!(" {}", script_domains.join(" "))
};
let connections = if connect_domains.is_empty() {
String::new()
} else {
format!(" {}", connect_domains.join(" "))
};
let frame_src = if frame_domains.is_empty() {
format!("frame-src 'self' {guest_origin}")
} else {
format!(
"frame-src 'self' {guest_origin} {}",
frame_domains.join(" ")
)
};
let base_uris = if base_uri_domains.is_empty() {
String::new()
} else {
format!(" {}", base_uri_domains.join(" "))
};
format!(
"default-src 'none'; \
script-src 'self' 'unsafe-inline'{resources}{scripts}; \
script-src-elem 'self' 'unsafe-inline'{resources}{scripts}; \
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}; \
object-src 'none'; \
base-uri 'self'{base_uris}"
)
}
async fn mcp_app_proxy(
State(state): State<AppState>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
Query(params): Query<ProxyQuery>,
) -> Response {
if params.secret != state.secret_key {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
if !peer_addr_is_loopback(&peer_addr) {
return (
StatusCode::BAD_REQUEST,
"MCP app proxy is only available to loopback clients",
)
.into_response();
}
let html = MCP_APP_PROXY_HTML.replace(
"{{OUTER_CSP}}",
&build_outer_csp(
&parse_domains(params.connect_domains.as_ref()),
&parse_domains(params.resource_domains.as_ref()),
&parse_domains(params.frame_domains.as_ref()),
&parse_domains(params.base_uri_domains.as_ref()),
&parse_domains(params.script_domains.as_ref()),
&state.guest_base_url,
),
);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(
header::HeaderName::from_static("referrer-policy"),
"no-referrer",
),
],
Html(html),
)
.into_response()
}
async fn store_guest_html(
State(state): State<AppState>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
Json(body): Json<StoreGuestBody>,
) -> Response {
if body.secret != state.secret_key {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
if !peer_addr_is_loopback(&peer_addr) {
return (
StatusCode::BAD_REQUEST,
"MCP app guest storage is only available to loopback clients",
)
.into_response();
}
let nonce = Uuid::new_v4().to_string();
let csp = body.csp.unwrap_or_default();
let guest_url = format!("{}/mcp-app-guest?nonce={}", state.guest_base_url, nonce);
{
let mut store = state.guest_store.write().await;
let cutoff = Instant::now() - Duration::from_secs(GUEST_HTML_TTL_SECS);
store.retain(|_, entry| entry.created > cutoff);
if store.len() >= GUEST_HTML_MAX_ENTRIES {
if let Some(oldest_key) = store
.iter()
.min_by_key(|(_, entry)| entry.created)
.map(|(key, _)| key.clone())
{
store.remove(&oldest_key);
}
}
store.insert(
nonce.clone(),
GuestHtmlEntry {
html: body.html,
csp,
created: Instant::now(),
},
);
}
(
StatusCode::OK,
Json(StoreGuestResponse { nonce, guest_url }),
)
.into_response()
}
async fn serve_guest_html(
State(state): State<GuestState>,
Query(params): Query<GuestQuery>,
) -> Response {
let entry = {
let mut store = state.guest_store.write().await;
let cutoff = Instant::now() - Duration::from_secs(GUEST_HTML_TTL_SECS);
store.retain(|_, entry| entry.created > cutoff);
store.get(&params.nonce).cloned()
};
match entry {
Some(entry) => {
let mut response = Html(entry.html).into_response();
let headers = response.headers_mut();
headers.insert(
header::HeaderName::from_static("referrer-policy"),
"strict-origin".parse().unwrap(),
);
if !entry.csp.is_empty() {
match HeaderValue::from_str(&entry.csp) {
Ok(csp) => {
headers.insert(header::CONTENT_SECURITY_POLICY, csp);
}
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid CSP").into_response(),
}
}
response
}
None => (StatusCode::NOT_FOUND, "Guest content not found").into_response(),
}
}
fn spawn_guest_server(guest_store: GuestHtmlStore) -> String {
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("failed to bind MCP app guest server");
let addr = listener
.local_addr()
.expect("failed to read MCP app guest server address");
listener
.set_nonblocking(true)
.expect("failed to configure MCP app guest server");
let listener = tokio::net::TcpListener::from_std(listener)
.expect("failed to create MCP app guest listener");
let app = Router::new()
.route("/mcp-app-guest", get(serve_guest_html))
.with_state(GuestState { guest_store });
tokio::spawn(async move {
if let Err(error) = axum::serve(listener, app).await {
tracing::error!(%error, "MCP app guest server stopped");
}
});
format!("http://{addr}")
}
pub(crate) fn routes(secret_key: String) -> Router {
let guest_store = Arc::new(RwLock::new(HashMap::new()));
let guest_base_url = spawn_guest_server(guest_store.clone());
let state = AppState {
secret_key,
guest_store,
guest_base_url,
};
Router::new()
.route("/mcp-app-proxy", get(mcp_app_proxy))
.route("/mcp-app-guest", post(store_guest_html))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::{normalize_csp_source, parse_domains, peer_addr_is_loopback};
use std::net::SocketAddr;
#[test]
fn normalizes_url_sources_to_origins() {
assert_eq!(
normalize_csp_source("https://cdn.example.com/assets/app.js"),
Some("https://cdn.example.com".to_string())
);
assert_eq!(
normalize_csp_source("wss://api.example.com/socket"),
Some("wss://api.example.com".to_string())
);
}
#[test]
fn accepts_wildcard_and_host_sources() {
assert_eq!(
normalize_csp_source("https://*.cloudflare.com"),
Some("https://*.cloudflare.com".to_string())
);
assert_eq!(
normalize_csp_source("cdn.example.com"),
Some("cdn.example.com".to_string())
);
assert_eq!(
normalize_csp_source("localhost:3000"),
Some("localhost:3000".to_string())
);
}
#[test]
fn rejects_unsafe_csp_sources() {
assert_eq!(normalize_csp_source("*"), None);
assert_eq!(normalize_csp_source("'unsafe-inline'"), None);
assert_eq!(normalize_csp_source("javascript:alert(1)"), None);
assert_eq!(normalize_csp_source("https://example.com;"), None);
assert_eq!(normalize_csp_source("https://user@example.com"), None);
}
#[test]
fn parse_domains_filters_invalid_sources() {
let domains =
"https://cdn.example.com/app.js, https://*.cloudflare.com, *, cdn.example.com"
.to_string();
assert_eq!(
parse_domains(Some(&domains)),
vec![
"https://cdn.example.com".to_string(),
"https://*.cloudflare.com".to_string(),
"cdn.example.com".to_string(),
]
);
}
#[test]
fn detects_loopback_peer_addresses() {
assert!(peer_addr_is_loopback(
&"127.0.0.1:12345".parse::<SocketAddr>().unwrap()
));
assert!(peer_addr_is_loopback(
&"[::1]:12345".parse::<SocketAddr>().unwrap()
));
assert!(!peer_addr_is_loopback(
&"192.168.1.10:12345".parse::<SocketAddr>().unwrap()
));
}
}
+1
View File
@@ -1,6 +1,7 @@
mod adapters;
mod common;
pub(crate) mod fs;
mod mcp_app_proxy;
mod provider;
pub mod server;
pub mod server_factory;
+40
View File
@@ -1580,6 +1580,9 @@ impl GooseAcpAgent {
};
let mut fields = ToolCallUpdateFields::new().status(status);
if let Some(raw_output) = extract_tool_raw_output(&tool_response.tool_result) {
fields = fields.raw_output(raw_output);
}
if !tool_response
.tool_result
.as_ref()
@@ -1791,6 +1794,13 @@ fn build_tool_call_content(tool_result: &ToolResult<CallToolResult>) -> Vec<Tool
}
}
fn extract_tool_raw_output(tool_result: &ToolResult<CallToolResult>) -> Option<serde_json::Value> {
tool_result
.as_ref()
.ok()
.and_then(|result| result.structured_content.clone())
}
impl GooseAcpAgent {
async fn on_initialize(
&self,
@@ -2234,6 +2244,11 @@ impl GooseAcpAgent {
};
let mut fields = ToolCallUpdateFields::new().status(status);
if let Some(raw_output) =
extract_tool_raw_output(&tool_response.tool_result)
{
fields = fields.raw_output(raw_output);
}
if !tool_response
.tool_result
.as_ref()
@@ -3411,6 +3426,31 @@ print(\"hello, world\")
);
}
#[test]
fn test_extract_tool_raw_output_preserves_structured_content() {
let mut result = CallToolResult::success(vec![RmcpContent::text("fallback")]);
result.structured_content = Some(serde_json::json!({
"restaurants": [
{
"name": "Coffee Shop",
"unitToken": "unit-1",
},
],
}));
assert_eq!(
extract_tool_raw_output(&Ok(result)),
Some(serde_json::json!({
"restaurants": [
{
"name": "Coffee Shop",
"unitToken": "unit-1",
},
],
})),
);
}
fn make_session_with_usage(
total_tokens: Option<i32>,
input_tokens: Option<i32>,
@@ -35,6 +35,14 @@ impl GooseAcpAgent {
self.on_get_tools(req).await
}
#[custom_method(GooseToolCallRequest)]
async fn dispatch_call_tool(
&self,
req: GooseToolCallRequest,
) -> Result<GooseToolCallResponse, sacp::Error> {
self.on_call_tool(req).await
}
#[custom_method(ReadResourceRequest)]
async fn dispatch_read_resource(
&self,
+61
View File
@@ -1,4 +1,6 @@
use super::*;
use crate::agents::reply_parts::is_tool_visible_to_app;
use rmcp::model::CallToolRequestParams;
impl GooseAcpAgent {
pub(super) async fn on_get_tools(
@@ -15,4 +17,63 @@ impl GooseAcpAgent {
.internal_err()?;
Ok(GetToolsResponse { tools: tools_json })
}
pub(super) async fn on_call_tool(
&self,
req: GooseToolCallRequest,
) -> Result<GooseToolCallResponse, sacp::Error> {
let internal_id = self.internal_session_id(&req.session_id).await?;
let agent = self.get_session_agent(&req.session_id, None).await?;
let tools = agent.list_tools(&internal_id, None).await;
let Some(tool) = tools.iter().find(|t| *t.name == req.name) else {
return Err(sacp::Error::invalid_params().data("tool not found"));
};
if !is_tool_visible_to_app(tool) {
return Err(sacp::Error::invalid_params().data("tool is not visible to app clients"));
}
let arguments = match req.arguments {
serde_json::Value::Object(map) => Some(map),
serde_json::Value::Null => None,
_ => {
return Err(sacp::Error::invalid_params().data("tool arguments must be an object"));
}
};
let tool_call = {
let mut params = CallToolRequestParams::new(req.name);
if let Some(args) = arguments {
params = params.with_arguments(args);
}
params
};
let ctx = crate::agents::ToolCallContext::new(internal_id, None, None);
let tool_result = agent
.extension_manager
.dispatch_tool_call(&ctx, tool_call, CancellationToken::new())
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let result = tool_result
.result
.await
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
let content = result
.content
.into_iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
Ok(GooseToolCallResponse {
content,
structured_content: result.structured_content,
is_error: result.is_error.unwrap_or(false),
meta: result.meta.and_then(|m| serde_json::to_value(m).ok()),
})
}
}
@@ -0,0 +1,233 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="referrer" content="no-referrer"/>
<meta name="color-scheme" content="light dark"/>
<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 getProxyParams() {
var params = new URLSearchParams(window.location.search);
var colorScheme = params.get('color_scheme');
return {
secret: params.get('secret') || '',
baseUrl: getProxyBaseUrl(),
colorScheme: colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : null
};
}
function getProxyBaseUrl() {
var marker = '/mcp-app-proxy';
var path = window.location.pathname || '';
var markerIndex = path.lastIndexOf(marker);
var prefix = markerIndex === -1 ? '' : path.slice(0, markerIndex);
return window.location.origin + prefix;
}
function applyProxyColorScheme(nextColorScheme) {
var proxyParams = getProxyParams();
var colorScheme = nextColorScheme || proxyParams.colorScheme || 'light dark';
document.documentElement.style.colorScheme = colorScheme;
document.body.style.colorScheme = colorScheme;
if (guestIframe) {
guestIframe.style.setProperty('color-scheme', colorScheme);
}
}
function createColorSchemePrelude(colorScheme) {
var hostColorScheme = colorScheme === 'dark' ? 'dark' : 'light';
var matchMediaScript = [
'<scr' + 'ipt>',
'(function(){',
'var nativeMatchMedia=window.matchMedia&&window.matchMedia.bind(window);',
'function normalizeColorScheme(value){return value==="dark"?"dark":"light";}',
'function setHostColorScheme(value){window.__mcpHostColorScheme=normalizeColorScheme(value);document.documentElement.style.colorScheme=window.__mcpHostColorScheme;if(document.body){document.body.style.colorScheme=window.__mcpHostColorScheme;}var meta=document.querySelector("meta[name=\\"color-scheme\\"]");if(meta){meta.setAttribute("content",window.__mcpHostColorScheme);}}',
'setHostColorScheme(' + JSON.stringify(hostColorScheme) + ');',
'document.addEventListener("DOMContentLoaded",function(){setHostColorScheme(window.__mcpHostColorScheme);});',
'if(nativeMatchMedia){window.matchMedia=function(query){var normalized=String(query).replace(/\\s+/g," ").trim().toLowerCase();var isDark=normalized==="(prefers-color-scheme: dark)";var isLight=normalized==="(prefers-color-scheme: light)";if(!isDark&&!isLight){return nativeMatchMedia(query);}return {matches:isDark?window.__mcpHostColorScheme==="dark":window.__mcpHostColorScheme==="light",media:String(query),onchange:null,addListener:function(){},removeListener:function(){},addEventListener:function(){},removeEventListener:function(){},dispatchEvent:function(){return false;}};};}',
'window.addEventListener("message",function(event){var data=event.data;if(!data||data.method!=="ui/notifications/host-context-changed"){return;}var theme=data.params&&data.params.theme;if(theme==="light"||theme==="dark"){setHostColorScheme(theme);}});',
'})();',
'</scr' + 'ipt>'
].join('');
return '<meta name="color-scheme" content="' + hostColorScheme + '"><style id="mcp-app-host-color-scheme">:root{color-scheme:' + hostColorScheme + ';}html,body{background-color:transparent;}</style>' + matchMediaScript;
}
function injectGuestColorScheme(html, colorScheme) {
if (!colorScheme) {
return html;
}
var prelude = createColorSchemePrelude(colorScheme);
var cleanedHtml = html.replace(/<meta\s+[^>]*name\s*=\s*["']color-scheme["'][^>]*>/gi, '');
if (/<head\b[^>]*>/i.test(cleanedHtml)) {
return cleanedHtml.replace(/<head\b[^>]*>/i, function (match) {
return match + prelude;
});
}
if (/<html\b[^>]*>/i.test(cleanedHtml)) {
return cleanedHtml.replace(/<html\b[^>]*>/i, function (match) {
return match + '<head>' + prelude + '</head>';
});
}
return prelude + cleanedHtml;
}
function renderSandboxError(message) {
if (guestIframe) {
guestIframe.remove();
guestIframe = null;
}
document.body.textContent = '';
var errorElement = document.createElement('div');
errorElement.setAttribute('role', 'alert');
errorElement.style.cssText = 'box-sizing:border-box; width:100%; height:100%; padding:16px; color:CanvasText; background:transparent; font:13px system-ui, sans-serif;';
errorElement.textContent = message;
document.body.appendChild(errorElement);
}
async function storeGuestHtml(html) {
var proxyParams = getProxyParams();
var cspMeta = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
var response = await fetch(proxyParams.baseUrl + '/mcp-app-guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: proxyParams.secret,
html: html,
csp: cspMeta ? cspMeta.content : ''
})
});
if (!response.ok) {
throw new Error('Failed to store guest HTML: ' + response.status);
}
var data = await response.json();
if (typeof data.guestUrl !== 'string') {
throw new Error('Guest URL missing from store response');
}
return data.guestUrl;
}
async function createGuestIframe(html, permissions) {
if (guestIframe) {
guestIframe.remove();
}
guestIframe = document.createElement('iframe');
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
guestIframe.referrerPolicy = 'no-referrer';
var allowList = [];
if (permissions && permissions.camera) allowList.push('camera');
if (permissions && permissions.microphone) allowList.push('microphone');
if (permissions && permissions.geolocation) allowList.push('geolocation');
if (permissions && permissions.clipboardWrite) allowList.push('clipboard-write');
if (allowList.length > 0) {
guestIframe.setAttribute('allow', allowList.join('; '));
}
var proxyParams = getProxyParams();
var colorScheme = proxyParams.colorScheme || 'light dark';
guestIframe.style.cssText = 'width:100%; height:100%; border:none; background-color:transparent; color-scheme:' + colorScheme + ';';
var guestHtml = injectGuestColorScheme(html, proxyParams.colorScheme);
try {
guestIframe.src = await storeGuestHtml(guestHtml);
} catch (e) {
console.error('Failed to store MCP app guest HTML:', e);
renderSandboxError('Unable to load MCP app sandbox.');
return;
}
document.body.appendChild(guestIframe);
}
function handleHostMessage(event) {
if (event.source !== window.parent) {
return;
}
var data = event.data;
if (!data || typeof data !== 'object') {
return;
}
if (data.method === 'ui/notifications/host-context-changed') {
var theme = data.params && data.params.theme;
if (theme === 'light' || theme === 'dark') {
applyProxyColorScheme(theme);
}
}
if (data.method === 'ui/notifications/sandbox-resource-ready') {
var params = data.params || {};
createGuestIframe(params.html || '', params.permissions || null);
return;
}
if (guestIframe && guestIframe.contentWindow) {
guestIframe.contentWindow.postMessage(data, '*');
}
}
function handleGuestMessage(event) {
if (!guestIframe || event.source !== guestIframe.contentWindow) {
return;
}
var data = event.data;
if (!data || typeof data !== 'object') {
return;
}
window.parent.postMessage(data, '*');
}
function handleMessage(event) {
if (event.source === window.parent) {
handleHostMessage(event);
} else if (guestIframe && event.source === guestIframe.contentWindow) {
handleGuestMessage(event);
}
}
window.addEventListener('message', handleMessage);
applyProxyColorScheme();
window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-proxy-ready',
params: {}
}, '*');
})();
</script>
</body>
</html>
+2 -1
View File
@@ -94,7 +94,7 @@ async fn health() -> &'static str {
"ok"
}
pub fn create_router(server: Arc<AcpServer>) -> Router {
pub fn create_router(server: Arc<AcpServer>, secret_key: String) -> Router {
let registry = Arc::new(connection::ConnectionRegistry::new(server));
let cors = CorsLayer::new()
@@ -121,5 +121,6 @@ pub fn create_router(server: Arc<AcpServer>) -> Router {
.route("/acp", post(http::handle_post).with_state(registry.clone()))
.route("/acp", get(handle_get).with_state(registry.clone()))
.route("/acp", delete(http::handle_delete).with_state(registry))
.merge(super::mcp_app_proxy::routes(secret_key))
.layer(cors)
}