diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 88ff8685b8aa..14b7d0b65c2b 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -36,6 +36,15 @@ use std::io::Read; use std::path::PathBuf; use tracing::warn; +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 +1095,19 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> config_dir: Paths::config_dir(), goose_platform: GoosePlatform::GooseCli, })); - let router = create_router(server); + let secret_key = + std::env::var("GOOSE_SERVER__SECRET_KEY").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::(), + ) + .await?; Ok(()) } diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index a3fc9e894c76..a50ade670ade 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -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(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + pub is_error: bool, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta")] + pub meta: Option, +} + /// Update the working directory for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/working_dir/update", response = EmptyResponse)] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 1d13987b405c..836d08eeb3e2 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -15,6 +15,11 @@ "requestType": "GetToolsRequest", "responseType": "GetToolsResponse" }, + { + "method": "_goose/tool/call", + "requestType": "GooseToolCallRequest", + "responseType": "GooseToolCallResponse" + }, { "method": "_goose/resource/read", "requestType": "ReadResourceRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index be922800fb7b..fbfaa0e0a43b 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -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": { @@ -1601,6 +1643,15 @@ "description": "Params for _goose/tools", "title": "GetToolsRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/GooseToolCallRequest" + } + ], + "description": "Params for _goose/tool/call", + "title": "GooseToolCallRequest" + }, { "allOf": [ { @@ -2007,6 +2058,14 @@ ], "title": "GetToolsResponse" }, + { + "allOf": [ + { + "$ref": "#/$defs/GooseToolCallResponse" + } + ], + "title": "GooseToolCallResponse" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/mcp_app_proxy.rs b/crates/goose/src/acp/mcp_app_proxy.rs new file mode 100644 index 000000000000..f1791e256692 --- /dev/null +++ b/crates/goose/src/acp/mcp_app_proxy.rs @@ -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>>; + +#[derive(Clone)] +struct GuestHtmlEntry { + html: String, + csp: String, + created: Instant, +} + +#[derive(Deserialize)] +struct ProxyQuery { + secret: String, + connect_domains: Option, + resource_domains: Option, + frame_domains: Option, + base_uri_domains: Option, + script_domains: Option, +} + +#[derive(Deserialize)] +struct GuestQuery { + nonce: String, +} + +#[derive(Deserialize)] +struct StoreGuestBody { + secret: String, + html: String, + csp: Option, +} + +#[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 { + 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::().is_err()) { + return false; + } + + let host = host.strip_prefix("*.").unwrap_or(host); + if host.eq_ignore_ascii_case("localhost") + || host.parse::().is_ok() + || host.parse::().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 { + 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, + ConnectInfo(peer_addr): ConnectInfo, + Query(params): Query, +) -> 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, + ConnectInfo(peer_addr): ConnectInfo, + Json(body): Json, +) -> 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, + Query(params): Query, +) -> 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::().unwrap() + )); + assert!(peer_addr_is_loopback( + &"[::1]:12345".parse::().unwrap() + )); + assert!(!peer_addr_is_loopback( + &"192.168.1.10:12345".parse::().unwrap() + )); + } +} diff --git a/crates/goose/src/acp/mod.rs b/crates/goose/src/acp/mod.rs index 3887b45b2887..594c14cac10e 100644 --- a/crates/goose/src/acp/mod.rs +++ b/crates/goose/src/acp/mod.rs @@ -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; diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 19a498430ce3..d7a91ebc12b9 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -35,6 +35,14 @@ impl GooseAcpAgent { self.on_get_tools(req).await } + #[custom_method(GooseToolCallRequest)] + async fn dispatch_call_tool( + &self, + req: GooseToolCallRequest, + ) -> Result { + self.on_call_tool(req).await + } + #[custom_method(ReadResourceRequest)] async fn dispatch_read_resource( &self, diff --git a/crates/goose/src/acp/server/tools.rs b/crates/goose/src/acp/server/tools.rs index e91d478341fa..ae6ca23e252f 100644 --- a/crates/goose/src/acp/server/tools.rs +++ b/crates/goose/src/acp/server/tools.rs @@ -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 { + 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::, _>>() + .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()), + }) + } } diff --git a/crates/goose/src/acp/templates/mcp_app_proxy.html b/crates/goose/src/acp/templates/mcp_app_proxy.html new file mode 100644 index 000000000000..eaaec1787686 --- /dev/null +++ b/crates/goose/src/acp/templates/mcp_app_proxy.html @@ -0,0 +1,233 @@ + + + + + + + + MCP App Sandbox + + + + + + diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index a9038431be3d..f4c76cb5f416 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -94,7 +94,7 @@ async fn health() -> &'static str { "ok" } -pub fn create_router(server: Arc) -> Router { +pub fn create_router(server: Arc, 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) -> 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) } diff --git a/ui/goose2/package.json b/ui/goose2/package.json index a1c36adc5648..aac4691497ad 100644 --- a/ui/goose2/package.json +++ b/ui/goose2/package.json @@ -29,6 +29,7 @@ "dependencies": { "@aaif/goose-sdk": "workspace:*", "@agentclientprotocol/sdk": "^0.19.0", + "@mcp-ui/client": "7.0.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index bc6f75bcba08..24ac6578dbc6 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -76,9 +76,14 @@ const EXCEPTIONS = { "Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.", }, "src/features/chat/ui/MessageBubble.tsx": { + limit: 580, + justification: + "Bubble rendering still owns assistant identity, grouped tool output, attachments, inline MCP app tool/result wiring, app-initiated message plumbing, full-width inline layout handling, inline auto-scroll callback plumbing, and the inline actions tray pending a later extraction pass.", + }, + "src/features/chat/ui/__tests__/MessageBubble.test.tsx": { limit: 520, justification: - "Bubble rendering still owns assistant identity, grouped tool output, attachments, and the inline actions tray pending a later extraction pass.", + "Message bubble regression coverage still keeps copy state, action tray layout, provider/persona identity, tool chains, and shared rendering behavior in one suite while the MCP app-specific assertions live in a companion test file.", }, "src/features/skills/ui/SkillsView.tsx": { limit: 620, diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index 2906da85b6bf..a16ef215d20b 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -1,14 +1,230 @@ use std::env; use crate::services::acp::GooseServeProcess; +use serde::Serialize; + +const GOOSE_SERVE_URL_ENV: &str = "GOOSE_SERVE_URL"; +const GOOSE_SERVER_SECRET_KEY_ENV: &str = "GOOSE_SERVER__SECRET_KEY"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GooseServeHostInfo { + pub http_base_url: String, + pub secret_key: String, +} #[tauri::command] pub async fn get_goose_serve_url(app_handle: tauri::AppHandle) -> Result { - if let Ok(url) = env::var("GOOSE_SERVE_URL") { - if !url.is_empty() { - return Ok(url); - } + if let Some(url) = configured_goose_serve_url() { + return Ok(url); } let process = GooseServeProcess::get(app_handle).await?; Ok(process.ws_url()) } + +#[tauri::command] +pub async fn get_goose_serve_host_info( + app_handle: tauri::AppHandle, +) -> Result { + if let Some(url) = configured_goose_serve_url() { + ensure_configured_goose_serve_supports_inline_apps(&url)?; + return Ok(GooseServeHostInfo { + http_base_url: goose_serve_http_base_url(&url)?, + secret_key: configured_goose_serve_secret_key()?, + }); + } + + let process = GooseServeProcess::get(app_handle).await?; + Ok(GooseServeHostInfo { + http_base_url: process.http_base_url(), + secret_key: process.secret_key().to_string(), + }) +} + +fn configured_goose_serve_url() -> Option { + env::var(GOOSE_SERVE_URL_ENV) + .ok() + .map(|url| url.trim().to_string()) + .filter(|url| !url.is_empty()) +} + +fn configured_goose_serve_secret_key() -> Result { + env::var(GOOSE_SERVER_SECRET_KEY_ENV) + .ok() + .map(|secret| secret.trim().to_string()) + .filter(|secret| !secret.is_empty()) + .ok_or_else(|| { + format!("{GOOSE_SERVER_SECRET_KEY_ENV} must be set when {GOOSE_SERVE_URL_ENV} is set") + }) +} + +fn goose_serve_http_base_url(goose_serve_url: &str) -> Result { + let (scheme, rest) = goose_serve_url + .trim() + .split_once("://") + .ok_or_else(|| format!("{GOOSE_SERVE_URL_ENV} must include a URL scheme"))?; + let http_scheme = match scheme { + "ws" => "http", + "http" => "http", + _ => { + return Err(format!( + "{GOOSE_SERVE_URL_ENV} must use ws or http for inline MCP apps because the app guest origin is served over local http" + )); + } + }; + let authority = rest + .split(['/', '?', '#']) + .next() + .filter(|authority| !authority.is_empty()) + .ok_or_else(|| format!("{GOOSE_SERVE_URL_ENV} must include a host"))?; + let path = rest + .get(authority.len()..) + .unwrap_or_default() + .split(['?', '#']) + .next() + .unwrap_or_default(); + let path_prefix = goose_serve_http_path_prefix(path); + + Ok(format!("{http_scheme}://{authority}{path_prefix}")) +} + +fn goose_serve_http_path_prefix(path: &str) -> String { + let path = path.trim_end_matches('/'); + if path.is_empty() || path == "/acp" { + return String::new(); + } + + if let Some(prefix) = path.strip_suffix("/acp") { + return prefix.to_string(); + } + + path.to_string() +} + +fn ensure_configured_goose_serve_supports_inline_apps(goose_serve_url: &str) -> Result<(), String> { + if !goose_serve_url_uses_plaintext_http(goose_serve_url)? { + return Err(format!( + "{GOOSE_SERVE_URL_ENV} must use ws or http for inline MCP apps because the app guest origin is served over local http" + )); + } + + if goose_serve_url_is_loopback(goose_serve_url)? { + return Ok(()); + } + + Err(format!( + "{GOOSE_SERVE_URL_ENV} must point to localhost for inline MCP apps because the app guest origin is served from a loopback-only sandbox" + )) +} + +fn goose_serve_url_uses_plaintext_http(goose_serve_url: &str) -> Result { + let (scheme, _) = goose_serve_url + .trim() + .split_once("://") + .ok_or_else(|| format!("{GOOSE_SERVE_URL_ENV} must include a URL scheme"))?; + Ok(matches!(scheme, "ws" | "http")) +} + +fn goose_serve_url_is_loopback(goose_serve_url: &str) -> Result { + let (_, rest) = goose_serve_url + .trim() + .split_once("://") + .ok_or_else(|| format!("{GOOSE_SERVE_URL_ENV} must include a URL scheme"))?; + let authority = rest + .split(['/', '?', '#']) + .next() + .filter(|authority| !authority.is_empty()) + .ok_or_else(|| format!("{GOOSE_SERVE_URL_ENV} must include a host"))?; + if authority.contains('@') { + return Ok(false); + } + + let host = if let Some(remainder) = authority.strip_prefix('[') { + remainder + .split_once(']') + .map(|(host, _)| host) + .unwrap_or(remainder) + } else { + authority.split(':').next().unwrap_or(authority) + } + .to_ascii_lowercase(); + + Ok(host == "localhost" + || host + .parse::() + .is_ok_and(|addr| addr.is_loopback()) + || host + .parse::() + .is_ok_and(|addr| addr.is_loopback())) +} + +#[cfg(test)] +mod tests { + use super::{ + ensure_configured_goose_serve_supports_inline_apps, goose_serve_http_base_url, + goose_serve_url_is_loopback, + }; + + #[test] + fn derives_http_base_url_from_websocket_url() { + assert_eq!( + goose_serve_http_base_url("ws://127.0.0.1:12345/acp").unwrap(), + "http://127.0.0.1:12345" + ); + assert_eq!( + goose_serve_http_base_url("http://localhost:3000/acp").unwrap(), + "http://localhost:3000" + ); + } + + #[test] + fn preserves_path_prefix_from_websocket_url() { + assert_eq!( + goose_serve_http_base_url("ws://localhost:3000/goose/acp").unwrap(), + "http://localhost:3000/goose" + ); + assert_eq!( + goose_serve_http_base_url("http://localhost:3000/goose/acp?token=abc").unwrap(), + "http://localhost:3000/goose" + ); + } + + #[test] + fn derives_http_base_url_without_path() { + assert_eq!( + goose_serve_http_base_url("http://localhost:3000").unwrap(), + "http://localhost:3000" + ); + } + + #[test] + fn rejects_invalid_goose_serve_url() { + assert!(goose_serve_http_base_url("localhost:3000").is_err()); + assert!(goose_serve_http_base_url("ftp://localhost:3000/acp").is_err()); + assert!(goose_serve_http_base_url("wss://localhost:3000/acp").is_err()); + assert!(goose_serve_http_base_url("https://localhost:3000/acp").is_err()); + assert!(goose_serve_http_base_url("ws:///acp").is_err()); + } + + #[test] + fn detects_loopback_goose_serve_urls() { + assert!(goose_serve_url_is_loopback("ws://127.0.0.1:12345/acp").unwrap()); + assert!(goose_serve_url_is_loopback("ws://localhost:12345/acp").unwrap()); + assert!(goose_serve_url_is_loopback("ws://[::1]:12345/acp").unwrap()); + assert!(!goose_serve_url_is_loopback("wss://example.test/acp").unwrap()); + } + + #[test] + fn rejects_remote_configured_urls_for_inline_apps() { + assert!( + ensure_configured_goose_serve_supports_inline_apps("ws://127.0.0.1:12345/acp").is_ok() + ); + assert!( + ensure_configured_goose_serve_supports_inline_apps("wss://example.test/acp").is_err() + ); + assert!( + ensure_configured_goose_serve_supports_inline_apps("wss://localhost:12345/acp") + .is_err() + ); + } +} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 921841453866..7414df6fbd41 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ pub fn run() { commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, + commands::acp::get_goose_serve_host_info, commands::projects::list_projects, commands::projects::create_project, commands::projects::update_project, diff --git a/ui/goose2/src-tauri/src/services/acp/goose_serve.rs b/ui/goose2/src-tauri/src/services/acp/goose_serve.rs index 2aa9b459374b..debbabf76cf2 100644 --- a/ui/goose2/src-tauri/src/services/acp/goose_serve.rs +++ b/ui/goose2/src-tauri/src/services/acp/goose_serve.rs @@ -20,6 +20,7 @@ const LOCALHOST: &str = "127.0.0.1"; /// concurrent sessions. pub struct GooseServeProcess { port: u16, + secret_key: String, _child: Child, } @@ -32,6 +33,16 @@ impl GooseServeProcess { format!("ws://{LOCALHOST}:{}/acp", self.port) } + /// Return the HTTP base URL for authenticated Goose server routes. + pub fn http_base_url(&self) -> String { + format!("http://{LOCALHOST}:{}", self.port) + } + + /// Return the secret key used to authenticate local HTTP requests. + pub fn secret_key(&self) -> &str { + &self.secret_key + } + /// Get a reference to the running process, or an error if it was never /// started (should not happen in normal operation). pub async fn get(app_handle: tauri::AppHandle) -> Result<&'static GooseServeProcess, String> { @@ -42,6 +53,7 @@ impl GooseServeProcess { async fn spawn(app_handle: tauri::AppHandle) -> Result { let port = reserve_free_port()?; + let secret_key = format!("goose2-{}", uuid::Uuid::new_v4().simple()); // Use a stable working directory for the long-lived server process. // Individual sessions will set their own cwd via the ACP protocol. @@ -63,6 +75,7 @@ impl GooseServeProcess { .arg("--port") .arg(port.to_string()) .current_dir(&working_dir) + .env("GOOSE_SERVER__SECRET_KEY", &secret_key) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -86,6 +99,7 @@ impl GooseServeProcess { Ok(GooseServeProcess { port, + secret_key, _child: child, }) } diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index 26abd164a0fd..b3035ce2305a 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -369,7 +369,7 @@ export function ChatInput({ return ( -
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for file attachments */} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index b5041f94513b..e0d9525299d4 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -11,6 +11,7 @@ import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext"; import { ChatContextPanel } from "./ChatContextPanel"; import { perfLog } from "@/shared/lib/perfLog"; import { useChatSessionController } from "../hooks/useChatSessionController"; +import type { Message } from "@/shared/types/messages"; interface ChatViewProps { sessionId: string; @@ -20,6 +21,26 @@ interface ChatViewProps { }) => void; } +function shouldOverlapComposerWithLatestMcpApp(messages: Message[]): boolean { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if ( + message.metadata?.userVisible === false || + (message.role === "assistant" && + message.content.length === 0 && + message.metadata?.completionStatus === "inProgress") + ) { + continue; + } + + return ( + message.role === "assistant" && message.content.at(-1)?.type === "mcpApp" + ); + } + + return false; +} + export function ChatView({ sessionId, onCreatePersona, @@ -34,6 +55,8 @@ export function ChatView({ const [globalArtifactRoot, setGlobalArtifactRoot] = useState( null, ); + const [isLoadingIndicatorMounted, setIsLoadingIndicatorMounted] = + useState(false); const controller = useChatSessionController({ sessionId, onCreatePersonaRequested: onCreatePersona, @@ -74,6 +97,19 @@ export function ChatView({ controller.chatState === "streaming" || controller.chatState === "waiting" || controller.chatState === "compacting"; + const shouldShowLoadingIndicator = + showIndicator && !controller.isLoadingHistory; + const shouldReserveComposerGap = + shouldShowLoadingIndicator || isLoadingIndicatorMounted; + const shouldOverlapComposer = + !shouldReserveComposerGap && + shouldOverlapComposerWithLatestMcpApp(controller.messages); + + useEffect(() => { + if (shouldShowLoadingIndicator) { + setIsLoadingIndicatorMounted(true); + } + }, [shouldShowLoadingIndicator]); return ( )} - - {showIndicator && !controller.isLoadingHistory ? ( + setIsLoadingIndicatorMounted(false)} + > + {shouldShowLoadingIndicator ? ( ; + toolResponse?: ToolResponseContent; + onSendMessage?: McpAppMessageHandler; + onAutoScrollRequest?: (element: HTMLElement | null) => void; } -export function McpAppView({ payload }: McpAppViewProps) { +const DEFAULT_APP_HEIGHT = 240; +// Goose2 currently only implements inline display mode. +type HostContextDisplayMode = NonNullable< + McpUiHostContext["availableDisplayModes"] +>[number]; +type AvailableDisplayMode = Extract; +const AVAILABLE_DISPLAY_MODES = [ + "inline", +] satisfies readonly AvailableDisplayMode[]; +const GOOSE2_USER_AGENT = `${packageJson.name}/${packageJson.version}`; +const DESKTOP_SAFE_AREA_INSETS = { + top: 0, + right: 0, + bottom: 0, + left: 0, +} as const; +type SizeChangedParams = Parameters< + NonNullable +>[0]; +type MessageParams = Parameters>[0]; +type CallToolResult = Awaited< + ReturnType> +>; +type ReadResourceResult = Awaited< + ReturnType> +>; +type HostContextToolInfo = NonNullable; +type HostContextTool = HostContextToolInfo["tool"]; + +function buildToolResult( + toolResponse: ToolResponseContent | undefined, +): CallToolResult | undefined { + if (!toolResponse) { + return undefined; + } + + return { + content: [{ type: "text", text: toolResponse.result }], + isError: toolResponse.isError, + structuredContent: + toolResponse.structuredContent as CallToolResult["structuredContent"], + }; +} + +function matchesMedia(query: string): boolean { + return window.matchMedia?.(query).matches ?? false; +} + +function getDeviceCapabilities(): NonNullable< + McpUiHostContext["deviceCapabilities"] +> { + return { + touch: + navigator.maxTouchPoints > 0 || + matchesMedia("(pointer: coarse)") || + matchesMedia("(any-pointer: coarse)"), + hover: matchesMedia("(hover: hover)") || matchesMedia("(any-hover: hover)"), + }; +} + +function buildHostContextToolInfo(payload: McpAppPayload): HostContextToolInfo { + const tool: HostContextTool = { + name: payload.tool.name, + title: payload.toolCallTitle, + inputSchema: { + type: "object", + }, + }; + + if (payload.tool.meta) { + tool._meta = payload.tool.meta; + } + + return { + id: payload.toolCallId, + tool, + }; +} + +export function McpAppView({ + payload, + toolInput, + toolResponse, + onSendMessage, + onAutoScrollRequest, +}: McpAppViewProps) { const { t } = useTranslation("chat"); + const { resolvedTheme } = useTheme(); + const [hostInfo, setHostInfo] = useState<{ + httpBaseUrl: string; + secretKey: string; + } | null>(null); + const [inlineHeight, setInlineHeight] = useState(DEFAULT_APP_HEIGHT); + const [renderError, setRenderError] = useState(null); + const [activeToolInput, setActiveToolInput] = useState< + Record | undefined + >(); + const [containerWidth, setContainerWidth] = useState(null); + const autoScrollTimersRef = useRef([]); + const rootRef = useRef(null); + const { + handleConfirmOpenLink, + handleOpenLink, + handleOpenLinkModalClose, + pendingOpenLinkUrl, + } = useMcpAppOpenLink(); + useIframeColorScheme(rootRef, resolvedTheme); + + const renderableDocument = useMemo( + () => extractRenderableMcpAppDocument(payload), + [payload], + ); + const initialToolResult = useMemo( + () => buildToolResult(toolResponse), + [toolResponse], + ); + const currentToolInput = activeToolInput ?? toolInput; + const currentToolResult = initialToolResult; + + const requestAutoScroll = useCallback(() => { + if (!onAutoScrollRequest) { + return; + } + + for (const timer of autoScrollTimersRef.current) { + window.clearTimeout(timer); + } + autoScrollTimersRef.current = []; + + const runAutoScroll = () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + onAutoScrollRequest(rootRef.current); + }); + }); + }; + + runAutoScroll(); + + for (const delay of [120, 300, 650]) { + const timer = window.setTimeout(() => { + runAutoScroll(); + }, delay); + autoScrollTimersRef.current.push(timer); + } + }, [onAutoScrollRequest]); + + useEffect( + () => () => { + for (const timer of autoScrollTimersRef.current) { + window.clearTimeout(timer); + } + autoScrollTimersRef.current = []; + }, + [], + ); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + return; + } + + const updateWidth = (width: number) => { + if (width > 0) { + setContainerWidth(Math.round(width)); + } + }; + + updateWidth(root.getBoundingClientRect().width); + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver((entries) => { + const nextWidth = entries[0]?.contentRect.width; + if (typeof nextWidth === "number") { + updateWidth(nextWidth); + } + }); + + observer.observe(root); + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + let cancelled = false; + + getGooseServeHostInfo() + .then((info) => { + if (!cancelled) { + setHostInfo(info); + } + }) + .catch(() => { + if (!cancelled) { + setRenderError(t("message.mcpAppRenderError")); + } + }); + + return () => { + cancelled = true; + }; + }, [t]); + + useEffect(() => { + if (!import.meta.env.DEV) { + return; + } + + console.groupCollapsed( + `[McpAppView] ${payload.tool.extensionName}/${payload.tool.name}`, + ); + console.debug("payload", payload); + console.debug("renderableDocument", renderableDocument); + console.debug("currentToolInput", currentToolInput ?? null); + console.debug("currentToolResult", currentToolResult ?? null); + console.debug("hostInfo", hostInfo); + console.groupEnd(); + }, [ + currentToolInput, + currentToolResult, + hostInfo, + payload, + renderableDocument, + ]); + + const sandbox = useMcpAppSandbox({ + hostInfo, + renderableDocument, + colorScheme: resolvedTheme, + }); + + const hostContext = useMemo( + () => ({ + theme: resolvedTheme, + displayMode: "inline", + availableDisplayModes: [...AVAILABLE_DISPLAY_MODES], + containerDimensions: + containerWidth !== null + ? { + width: containerWidth, + height: inlineHeight, + } + : undefined, + locale: navigator.language, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: GOOSE2_USER_AGENT, + platform: "desktop", + deviceCapabilities: getDeviceCapabilities(), + safeAreaInsets: DESKTOP_SAFE_AREA_INSETS, + toolInfo: buildHostContextToolInfo(payload), + }), + [containerWidth, inlineHeight, payload, resolvedTheme], + ); + + const handleMessage = useCallback( + async ({ role, content }: MessageParams) => { + if (role !== "user" || !onSendMessage) { + return { isError: true }; + } + + const text = content + .filter((block): block is { type: "text"; text: string } => { + return ( + block.type === "text" && + typeof block.text === "string" && + block.text.trim().length > 0 + ); + }) + .map((block) => block.text.trim()) + .join("\n\n"); + + if (!text) { + return { isError: true }; + } + + const accepted = await onSendMessage(text); + return accepted === false ? { isError: true } : {}; + }, + [onSendMessage], + ); + + const handleCallTool = useCallback( + async ({ + name, + arguments: args, + }: { + name: string; + arguments?: Record; + }) => { + const acpSessionId = payload.gooseSessionId ?? payload.sessionId; + + setActiveToolInput(args ?? {}); + + const client = await getClient(); + const response = (await client.extMethod("_goose/tool/call", { + sessionId: acpSessionId, + name: `${payload.tool.extensionName}__${name}`, + arguments: args ?? {}, + })) as GooseToolCallResponse; + + const toolResult: CallToolResult = { + content: (response.content ?? []) as CallToolResult["content"], + isError: response.isError, + structuredContent: + response.structuredContent as CallToolResult["structuredContent"], + _meta: response._meta as CallToolResult["_meta"], + }; + + return toolResult; + }, + [payload.gooseSessionId, payload.sessionId, payload.tool.extensionName], + ); + + const handleReadResource = useCallback( + async ({ uri }: { uri: string }) => { + const acpSessionId = payload.gooseSessionId ?? payload.sessionId; + const client = await getClient(); + const response = await client.goose.GooseResourceRead({ + sessionId: acpSessionId, + uri, + extensionName: payload.tool.extensionName, + }); + + return (response.result ?? { contents: [] }) as ReadResourceResult; + }, + [payload.gooseSessionId, payload.sessionId, payload.tool.extensionName], + ); + + const handleSizeChanged = useCallback( + ({ height }: SizeChangedParams) => { + if (typeof height === "number" && height > 0) { + setInlineHeight(height); + requestAutoScroll(); + } + }, + [requestAutoScroll], + ); + + const handleRenderError = useCallback(() => { + setRenderError(t("message.mcpAppRenderError")); + }, [t]); + + const shouldRenderApp = + renderableDocument !== null && sandbox !== null && renderError === null; + const shouldShowFallback = + renderError !== null || renderableDocument === null; + const rootClassName = "my-3 w-full"; + const appChromeClassName = renderableDocument?.prefersBorder + ? "w-full overflow-hidden rounded-2xl border border-border-primary bg-background/40 shadow-sm [&_iframe]:block" + : "w-full bg-transparent [&_iframe]:block"; + const appChromeStyle = { + height: inlineHeight, + colorScheme: resolvedTheme, + } as const; + const loadingClassName = renderableDocument?.prefersBorder + ? "rounded-2xl border border-dashed border-border px-4 py-3 text-muted-foreground text-sm" + : "py-1 text-muted-foreground text-sm"; + + useEffect(() => { + if (!import.meta.env.DEV || !shouldShowFallback) { + return; + } + + console.debug("[McpAppView] fallback", { + payload, + renderableDocument, + renderError, + readError: payload.resource.readError, + }); + }, [payload, renderableDocument, renderError, shouldShowFallback]); + + useEffect(() => { + if (!shouldRenderApp) { + return; + } + + requestAutoScroll(); + }, [requestAutoScroll, shouldRenderApp]); - // Currently we just render the MCP App payload as JSON. - // Up next, we'll replace this with actual HTML rendering and host bridging. return ( -
-
- {t("message.mcpAppUnderConstruction")} -
- +
+ {shouldRenderApp ? ( +
+ +
+ ) : renderableDocument && renderError === null ? ( +
{t("message.mcpAppLoading")}
+ ) : null} + + {shouldShowFallback && ( +
+
+ {t("message.mcpApp")} +
+ {(renderError || payload.resource.readError) && ( +

+ {renderError ?? payload.resource.readError} +

+ )} +
+ )} +
); } diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index ab521e87d118..86b746a35075 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -19,6 +19,7 @@ import { ReasoningTrigger, ReasoningContent, } from "@/shared/ui/ai-elements/reasoning"; +import type { McpAppMessageHandler } from "./mcpAppTypes"; import { ToolChainCards, type ToolChainItem } from "./ToolChainCards"; import { ClickableImage } from "./ClickableImage"; import { McpAppView } from "./McpAppView"; @@ -29,6 +30,8 @@ import type { MessageContent, TextContent, ImageContent, + McpAppContent, + ToolRequestContent, ToolResponseContent, ThinkingContent, ReasoningContent as ReasoningContentType, @@ -81,6 +84,8 @@ interface MessageBubbleProps { onCopy?: () => void; onRetryMessage?: (messageId: string) => void; onEditMessage?: (messageId: string) => void; + onSendMcpAppMessage?: McpAppMessageHandler; + onMcpAppAutoScroll?: (element: HTMLElement | null) => void; } interface ContentSection { @@ -185,6 +190,9 @@ function renderContentBlock( options: { defaultImageAlt: string; redactedThinking: string; + contentBlocks: MessageContent[]; + onSendMcpAppMessage?: McpAppMessageHandler; + onMcpAppAutoScroll?: (element: HTMLElement | null) => void; }, isStreamingMsg?: boolean, isUserMessage?: boolean, @@ -226,8 +234,30 @@ function renderContentBlock( case "toolResponse": // Handled by groupContentSections toolChain rendering return null; - case "mcpApp": - return ; + case "mcpApp": { + const mcpApp = content as McpAppContent; + const matchingToolInput = options.contentBlocks.find( + (block): block is ToolRequestContent => + block.type === "toolRequest" && + block.id === mcpApp.payload.toolCallId, + ); + const matchingToolResponse = options.contentBlocks.find( + (block): block is ToolResponseContent => + block.type === "toolResponse" && + block.id === mcpApp.payload.toolCallId, + ); + + return ( + + ); + } case "thinking": case "reasoning": { const text = (content as ThinkingContent | ReasoningContentType).text; @@ -282,6 +312,8 @@ export const MessageBubble = memo(function MessageBubble({ isStreaming, onRetryMessage, onEditMessage, + onSendMcpAppMessage, + onMcpAppAutoScroll, }: MessageBubbleProps) { const { t } = useTranslation(["chat", "common"]); const { formatDate } = useLocaleFormatting(); @@ -305,6 +337,7 @@ export const MessageBubble = memo(function MessageBubble({ .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) .join("\n"); + const hasMcpApp = content.some((block) => block.type === "mcpApp"); if (role === "system") { return ( @@ -314,6 +347,7 @@ export const MessageBubble = memo(function MessageBubble({ renderContentBlock(c, i, { defaultImageAlt: t("message.defaultImageAlt"), redactedThinking: t("message.redactedThinking"), + contentBlocks: content, }), )}
@@ -363,7 +397,11 @@ export const MessageBubble = memo(function MessageBubble({
{showAssistantIdentity ? ( @@ -434,6 +472,9 @@ export const MessageBubble = memo(function MessageBubble({ { defaultImageAlt: t("message.defaultImageAlt"), redactedThinking: t("message.redactedThinking"), + contentBlocks: content, + onSendMcpAppMessage, + onMcpAppAutoScroll, }, isStreaming, isUser, diff --git a/ui/goose2/src/features/chat/ui/MessageTimeline.tsx b/ui/goose2/src/features/chat/ui/MessageTimeline.tsx index fdf3777c0273..3eeb6e5add5f 100644 --- a/ui/goose2/src/features/chat/ui/MessageTimeline.tsx +++ b/ui/goose2/src/features/chat/ui/MessageTimeline.tsx @@ -1,10 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/shared/lib/cn"; import { useLocaleFormatting } from "@/shared/i18n"; import { MessageBubble } from "./MessageBubble"; +import type { McpAppMessageHandler } from "./mcpAppTypes"; import { getTextContent, type Message } from "@/shared/types/messages"; +const AUTO_SCROLL_THRESHOLD_PX = 180; +const MCP_APP_STICKY_SCROLL_MS = 1500; + interface MessageTimelineProps { messages: Message[]; streamingMessageId?: string | null; @@ -13,6 +17,7 @@ interface MessageTimelineProps { onScrollTargetHandled?: (messageId: string) => void; onRetryMessage?: (messageId: string) => void; onEditMessage?: (messageId: string) => void; + onSendMcpAppMessage?: McpAppMessageHandler; className?: string; } @@ -57,14 +62,17 @@ export function MessageTimeline({ onScrollTargetHandled, onRetryMessage, onEditMessage, + onSendMcpAppMessage, className, }: MessageTimelineProps) { const { t } = useTranslation("chat"); const { formatDate } = useLocaleFormatting(); - const bottomRef = useRef(null); const containerRef = useRef(null); const messageRefs = useRef>({}); const isNearBottomRef = useRef(true); + const stickyScrollUntilRef = useRef(0); + const autoScrollTimersRef = useRef([]); + const lastMcpAppSignatureRef = useRef(null); const [pulsingMessageId, setPulsingMessageId] = useState(null); const visibleMessages = messages.filter( (m) => @@ -96,16 +104,112 @@ export function MessageTimeline({ return textMatch?.id ?? null; }, [scrollTargetMessageId, scrollTargetQuery, visibleMessages]); + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + const container = containerRef.current; + if (!container) { + return; + } + + container.scrollTo({ + top: container.scrollHeight, + behavior, + }); + }, []); + + const scrollToBottomIfNearBottom = useCallback( + (behavior: ScrollBehavior = "smooth") => { + const container = containerRef.current; + if (!container) { + return; + } + + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const stickyActive = stickyScrollUntilRef.current > performance.now(); + + if ( + !isNearBottomRef.current && + !stickyActive && + distanceFromBottom >= AUTO_SCROLL_THRESHOLD_PX + ) { + return; + } + + scrollToBottom(behavior); + }, + [scrollToBottom], + ); + + const schedulePinnedBottomBurst = useCallback(() => { + stickyScrollUntilRef.current = performance.now() + MCP_APP_STICKY_SCROLL_MS; + + for (const timer of autoScrollTimersRef.current) { + window.clearTimeout(timer); + } + autoScrollTimersRef.current = []; + + const run = () => { + scrollToBottom("auto"); + }; + + run(); + + for (const delay of [120, 300, 650]) { + const timer = window.setTimeout(() => { + run(); + }, delay); + autoScrollTimersRef.current.push(timer); + } + }, [scrollToBottom]); + + const requestMcpAppAutoScroll = useCallback((element: HTMLElement | null) => { + const container = containerRef.current; + if (!container || !element) { + return; + } + + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const shouldStick = + isNearBottomRef.current || + distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX || + stickyScrollUntilRef.current > performance.now(); + + if (!shouldStick) { + return; + } + + stickyScrollUntilRef.current = performance.now() + MCP_APP_STICKY_SCROLL_MS; + + const alignElementBottom = () => { + const nextContainer = containerRef.current; + if (!nextContainer || !element.isConnected) { + return; + } + + const containerRect = nextContainer.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const delta = elementRect.bottom - containerRect.bottom + 16; + + if (delta > 0) { + nextContainer.scrollBy({ + top: delta, + behavior: "auto", + }); + } + }; + + alignElementBottom(); + requestAnimationFrame(() => { + alignElementBottom(); + }); + }, []); + // Use scrollTo instead of scrollIntoView to avoid scrolling parent/document-level ancestors. // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable and don't need to be in deps useEffect(() => { - if (isNearBottomRef.current && containerRef.current) { - containerRef.current.scrollTo({ - top: containerRef.current.scrollHeight, - behavior: "smooth", - }); - } - }, [messages, streamingMessageId]); + scrollToBottomIfNearBottom(); + }, [messages, scrollToBottomIfNearBottom, streamingMessageId]); useEffect(() => { if (!resolvedScrollTargetMessageId) { @@ -143,11 +247,54 @@ export function MessageTimeline({ return () => window.clearTimeout(timer); }, [pulsingMessageId]); + useEffect( + () => () => { + for (const timer of autoScrollTimersRef.current) { + window.clearTimeout(timer); + } + autoScrollTimersRef.current = []; + }, + [], + ); + + useEffect(() => { + const lastMessage = visibleMessages.at(-1); + if (!lastMessage || lastMessage.role !== "assistant") { + lastMcpAppSignatureRef.current = null; + return; + } + + const mcpAppCount = lastMessage.content.filter( + (block) => block.type === "mcpApp", + ).length; + if (mcpAppCount === 0) { + lastMcpAppSignatureRef.current = null; + return; + } + + const signature = `${lastMessage.id}:${mcpAppCount}:${lastMessage.content.length}`; + if (lastMcpAppSignatureRef.current === signature) { + return; + } + lastMcpAppSignatureRef.current = signature; + + if ( + isNearBottomRef.current || + stickyScrollUntilRef.current > performance.now() + ) { + schedulePinnedBottomBurst(); + } + }, [schedulePinnedBottomBurst, visibleMessages]); + const handleScroll = () => { const container = containerRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; - isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + isNearBottomRef.current = distanceFromBottom < AUTO_SCROLL_THRESHOLD_PX; + if (distanceFromBottom >= AUTO_SCROLL_THRESHOLD_PX) { + stickyScrollUntilRef.current = 0; + } }; if (visibleMessages.length === 0) { @@ -214,12 +361,12 @@ export function MessageTimeline({ onEditMessage={ message.role === "user" ? onEditMessage : undefined } + onSendMcpAppMessage={onSendMcpAppMessage} + onMcpAppAutoScroll={requestMcpAppAutoScroll} />
); })} - -
); diff --git a/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx b/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx index 3701c529441b..ac2d69b13d5e 100644 --- a/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx +++ b/ui/goose2/src/features/chat/ui/ToolCallAdapter.tsx @@ -3,6 +3,11 @@ import { useTranslation } from "react-i18next"; import { FolderOpen, ChevronRight } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/shared/ui/collapsible"; import { Tool, ToolHeader, @@ -20,6 +25,7 @@ interface ToolCallAdapterProps { arguments: Record; status: ToolCallStatus; result?: string; + structuredContent?: unknown; isError?: boolean; /** Epoch ms when the tool call started executing. */ startedAt?: number; @@ -208,13 +214,33 @@ export function ToolCallAdapter({ arguments: args, status, result, + structuredContent, isError, startedAt, open, onOpenChange, }: ToolCallAdapterProps) { + const { t } = useTranslation("chat"); const elapsed = useElapsedTime(status, startedAt); const state = toolStatusMap[status]; + const [structuredOutputOpen, setStructuredOutputOpen] = useState(false); + + const structuredOutputText = useMemo(() => { + if (structuredContent === undefined) return null; + if (typeof structuredContent === "string") return structuredContent; + + try { + return JSON.stringify(structuredContent, null, 2); + } catch { + return String(structuredContent); + } + }, [structuredContent]); + const structuredOutputLineCount = + structuredOutputText?.split("\n").length ?? 0; + const shouldCollapseStructuredOutput = + !isError && + structuredOutputText !== null && + (structuredOutputLineCount > 14 || structuredOutputText.length > 1600); const elapsedSeconds = status === "executing" && elapsed >= 3 ? elapsed : undefined; @@ -236,6 +262,44 @@ export function ToolCallAdapter({ output={isError ? undefined : result} errorText={isError ? result : undefined} /> + {!isError && + structuredContent !== undefined && + (shouldCollapseStructuredOutput ? ( + + + + {t("tools.structuredOutput")} + + {t("tools.structuredOutputLines", { + count: structuredOutputLineCount, + })} + + + + + + + ) : ( + + ))} diff --git a/ui/goose2/src/features/chat/ui/ToolChainCards.tsx b/ui/goose2/src/features/chat/ui/ToolChainCards.tsx index 5ad7cc0a80fe..17518845d7d0 100644 --- a/ui/goose2/src/features/chat/ui/ToolChainCards.tsx +++ b/ui/goose2/src/features/chat/ui/ToolChainCards.tsx @@ -133,6 +133,7 @@ export function ToolChainCards({ toolItems }: { toolItems: ToolChainItem[] }) { arguments={request?.arguments ?? {}} status={status} result={response?.result} + structuredContent={response?.structuredContent} isError={response?.isError} startedAt={request?.startedAt} open={expandedKeys.has(item.key)} diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx new file mode 100644 index 000000000000..1d831a2dca42 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -0,0 +1,201 @@ +import type { ReactNode } from "react"; +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatView } from "../ChatView"; + +const mocks = vi.hoisted(() => ({ + messageTimelineSpy: vi.fn(), + chatInputSpy: vi.fn(), + handleSend: vi.fn(() => true), + useChatSessionController: vi.fn(), +})); + +vi.mock("motion/react", () => ({ + AnimatePresence: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("../MessageTimeline", () => ({ + MessageTimeline: (props: unknown) => { + mocks.messageTimelineSpy(props); + return
; + }, +})); + +vi.mock("../ChatInput", () => ({ + ChatInput: (props: unknown) => { + mocks.chatInputSpy(props); + return
; + }, +})); + +vi.mock("../LoadingGoose", () => ({ + LoadingGoose: () => null, +})); + +vi.mock("../ChatLoadingSkeleton", () => ({ + ChatLoadingSkeleton: () =>
, +})); + +vi.mock("../ChatContextPanel", () => ({ + ChatContextPanel: () => null, +})); + +vi.mock("../../hooks/ArtifactPolicyContext", () => ({ + ArtifactPolicyProvider: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock("../../hooks/useChatSessionController", () => ({ + useChatSessionController: mocks.useChatSessionController, +})); + +vi.mock("../../stores/chatSessionStore", () => ({ + useChatSessionStore: (selector: (state: unknown) => unknown) => + selector({ + contextPanelOpenBySession: {}, + setContextPanelOpen: vi.fn(), + }), +})); + +vi.mock("@/features/projects/lib/chatProjectContext", () => ({ + defaultGlobalArtifactRoot: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/shared/lib/perfLog", () => ({ + perfLog: vi.fn(), +})); + +describe("ChatView MCP app messaging", () => { + beforeEach(() => { + mocks.messageTimelineSpy.mockClear(); + mocks.chatInputSpy.mockClear(); + mocks.handleSend.mockClear(); + mocks.useChatSessionController.mockReturnValue({ + messages: [], + streamingMessageId: null, + scrollTarget: null, + handleScrollTargetHandled: vi.fn(), + handleSend: mocks.handleSend, + isLoadingHistory: false, + chatState: "idle", + stopStreaming: vi.fn(), + projectMetadataPending: false, + isCompactingContext: false, + queue: { queuedMessage: null, dismiss: vi.fn() }, + draftValue: "", + handleDraftChange: vi.fn(), + personas: [], + selectedPersonaId: null, + handlePersonaChange: vi.fn(), + handleCreatePersona: vi.fn(), + pickerAgents: [], + providersLoading: false, + selectedProvider: "goose", + handleProviderChange: vi.fn(), + currentModelId: null, + currentModelName: null, + availableModels: [], + modelsLoading: false, + modelStatusMessage: null, + handleModelChange: vi.fn(), + selectedProjectId: null, + availableProjects: [], + handleProjectChange: vi.fn(), + tokenState: { accumulatedTotal: 0, contextLimit: 0 }, + isContextUsageReady: false, + compactConversation: vi.fn(), + canCompactContext: false, + supportsCompactionControls: false, + allowedArtifactRoots: [], + project: null, + }); + }); + + it("passes handleSend through to MessageTimeline for MCP app messages", () => { + render(); + + expect(mocks.messageTimelineSpy).toHaveBeenCalled(); + const timelineProps = mocks.messageTimelineSpy.mock.calls.at(-1)?.[0] as { + onSendMcpAppMessage?: unknown; + }; + + expect(timelineProps.onSendMcpAppMessage).toBe(mocks.handleSend); + const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { + className?: string; + }; + expect(chatInputProps.className).toBeUndefined(); + }); + + it("overlaps the composer when the latest visible content is an MCP app", () => { + mocks.useChatSessionController.mockReturnValue({ + ...mocks.useChatSessionController(), + messages: [ + { + id: "assistant-1", + role: "assistant", + created: Date.now(), + content: [ + { + type: "mcpApp", + id: "mcp-app-1", + payload: {}, + }, + ], + }, + ], + }); + + render(); + + expect(mocks.chatInputSpy).toHaveBeenCalled(); + const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { + className?: string; + }; + expect(chatInputProps.className).toBe("-mt-4"); + }); + + it("does not overlap the composer when reasoning is the latest visible content", () => { + mocks.useChatSessionController.mockReturnValue({ + ...mocks.useChatSessionController(), + messages: [ + { + id: "assistant-1", + role: "assistant", + created: Date.now(), + content: [ + { + type: "reasoning", + text: "Working through it", + }, + ], + }, + ], + }); + + render(); + + expect(mocks.chatInputSpy).toHaveBeenCalled(); + const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { + className?: string; + }; + expect(chatInputProps.className).toBeUndefined(); + }); + + it("does not overlap the composer over the loading indicator", () => { + mocks.useChatSessionController.mockReturnValue({ + ...mocks.useChatSessionController(), + chatState: "thinking", + }); + + render(); + + expect(mocks.chatInputSpy).toHaveBeenCalled(); + const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { + className?: string; + }; + expect(chatInputProps.className).toBeUndefined(); + }); +}); diff --git a/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx new file mode 100644 index 000000000000..bbce7fa47e33 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -0,0 +1,468 @@ +import type { AppRendererProps, RequestHandlerExtra } from "@mcp-ui/client"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { McpAppView } from "../McpAppView"; +import type { + McpAppPayload, + ToolResponseContent, +} from "@/shared/types/messages"; + +const mocks = vi.hoisted(() => ({ + appRendererSpy: vi.fn(), + nestedToolResultSpy: vi.fn(), + extMethod: vi.fn(), + getClient: vi.fn(), + resolvedTheme: "dark" as "light" | "dark", +})); + +vi.mock("@mcp-ui/client", () => ({ + UI_EXTENSION_CONFIG: { mimeTypes: ["text/html;profile=mcp-app"] }, + AppRenderer: (props: AppRendererProps) => { + mocks.appRendererSpy(props); + + return ( +
+