render mcp apps inline in goose2 (#8877)
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
"requestType": "GetToolsRequest",
|
||||
"responseType": "GetToolsResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/tool/call",
|
||||
"requestType": "GooseToolCallRequest",
|
||||
"responseType": "GooseToolCallResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/resource/read",
|
||||
"requestType": "ReadResourceRequest",
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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(¶ms.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,6 +1,7 @@
|
||||
mod adapters;
|
||||
mod common;
|
||||
pub(crate) mod fs;
|
||||
mod mcp_app_proxy;
|
||||
mod provider;
|
||||
pub mod server;
|
||||
pub mod server_factory;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user