diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ecf7f81d766d..88ff8685b8aa 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell as ClapShell}; +use goose::agents::GoosePlatform; use goose::builtin_extension::register_builtin_extensions; use goose::config::{Config, GooseMode}; #[cfg(feature = "telemetry")] @@ -1083,6 +1084,7 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), + goose_platform: GoosePlatform::GooseCli, })); let router = create_router(server); diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index a7c873a1fe6f..ea79f7717b97 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3,7 +3,7 @@ use crate::acp::fs::AcpTools; use crate::acp::tools::AcpAwareToolMeta; use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL}; use crate::agents::extension::{Envs, PLATFORM_EXTENSIONS}; -use crate::agents::mcp_client::McpClientTrait; +use crate::agents::mcp_client::{GooseMcpHostInfo, McpClientTrait}; use crate::agents::platform_extensions::developer::DeveloperClient; use crate::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig}; use crate::config::base::CONFIG_YAML_NAME; @@ -59,6 +59,7 @@ use sacp::{ Agent as SacpAgent, ByteStreams, Client, ConnectionTo, Dispatch, HandleDispatchFrom, Handled, Responder, }; +use serde::Deserialize; use std::collections::HashMap; use std::sync::Arc; use strum::{EnumMessage, VariantNames}; @@ -182,6 +183,7 @@ pub struct GooseAcpAgent { builtins: Vec, client_fs_capabilities: OnceCell, client_terminal: OnceCell, + client_mcp_host_info: OnceCell, config_dir: std::path::PathBuf, session_manager: Arc, thread_manager: Arc, @@ -189,6 +191,7 @@ pub struct GooseAcpAgent { goose_mode: GooseMode, disable_session_naming: bool, provider_inventory: ProviderInventoryService, + goose_platform: GoosePlatform, } /// Shorten a session/thread id for perf log correlation. @@ -253,6 +256,52 @@ fn extract_timeout_from_meta(meta: &Option) -> Option { .and_then(|v| v.as_u64()) } +#[derive(Debug, Default, Deserialize)] +struct GooseClientMetaEnvelope { + #[serde(default)] + goose: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct GooseClientMeta { + #[serde(rename = "mcpHostCapabilities", default)] + mcp_host_capabilities: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct GooseMcpHostCapabilities { + #[serde(default)] + extensions: Option, +} + +fn extract_goose_client_meta(meta: &Meta) -> Option { + serde_json::from_value(serde_json::Value::Object(meta.clone())).ok() +} + +fn extract_client_mcp_host_info(args: &InitializeRequest) -> GooseMcpHostInfo { + let host_capabilities = args + .client_capabilities + .meta + .as_ref() + .and_then(extract_goose_client_meta) + .and_then(|meta| meta.goose) + .and_then(|goose| goose.mcp_host_capabilities); + let explicit_extensions = host_capabilities + .as_ref() + .and_then(|capabilities| capabilities.extensions.as_ref()) + .is_some(); + let extensions = host_capabilities + .and_then(|capabilities| capabilities.extensions) + .unwrap_or_default(); + + GooseMcpHostInfo { + explicit_extensions, + extensions, + client_name: args.client_info.as_ref().map(|info| info.name.clone()), + client_version: args.client_info.as_ref().map(|info| info.version.clone()), + } +} + fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result { match mcp_server { McpServer::Stdio(stdio) => { @@ -795,6 +844,7 @@ impl GooseAcpAgent { config_dir: std::path::PathBuf, goose_mode: GooseMode, disable_session_naming: bool, + goose_platform: GoosePlatform, ) -> Result { let session_manager = Arc::new(SessionManager::new(data_dir)); let thread_manager = Arc::new(crate::session::ThreadManager::new( @@ -809,6 +859,7 @@ impl GooseAcpAgent { builtins, client_fs_capabilities: OnceCell::new(), client_terminal: OnceCell::new(), + client_mcp_host_info: OnceCell::new(), config_dir, session_manager, thread_manager, @@ -816,6 +867,7 @@ impl GooseAcpAgent { goose_mode, disable_session_naming, provider_inventory, + goose_platform, }) } @@ -986,12 +1038,14 @@ impl GooseAcpAgent { .cloned() .unwrap_or_default(); let client_terminal = self.client_terminal.get().copied().unwrap_or(false); + let client_mcp_host_info = self.client_mcp_host_info.get().cloned(); let provider_factory = Arc::clone(&self.provider_factory); let disable_session_naming = self.disable_session_naming; + let goose_platform = self.goose_platform.clone(); tokio::spawn(async move { let t_setup = std::time::Instant::now(); - + debug!(target: "perf", sid = %sid, "perf: agent_setup start (background)"); // Shared config — read once, used by both phases. let config = match Config::new(config_dir.join(CONFIG_YAML_NAME), "goose") { Ok(c) => c, @@ -1005,14 +1059,17 @@ impl GooseAcpAgent { // ── Phase 1: create agent + init provider (fast, ~55ms) ────── let phase1: Result, String> = async { - let agent = Arc::new(Agent::with_config(AgentConfig::new( - session_manager, - permission_manager, - None, - goose_mode, - disable_session_naming, - GoosePlatform::GooseCli, - ))); + let agent = Arc::new(Agent::with_config( + AgentConfig::new( + session_manager, + permission_manager, + None, + goose_mode, + disable_session_naming, + goose_platform, + ) + .with_mcp_host_info(client_mcp_host_info), + )); // Init provider — reuse the pre-resolved name + model when // available (already computed in on_new_session), otherwise @@ -1475,12 +1532,11 @@ impl GooseAcpAgent { } } + let update = ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields) + .meta(extract_tool_call_update_meta(tool_response)); cx.send_notification(SessionNotification::new( session_id.clone(), - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - ToolCallId::new(tool_response.id.clone()), - fields, - )), + SessionUpdate::ToolCallUpdate(update), ))?; Ok(()) @@ -1572,6 +1628,16 @@ fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConf } } +fn extract_tool_call_update_meta( + tool_response: &crate::conversation::message::ToolResponse, +) -> Option { + let tool_result = tool_response.tool_result.as_ref().ok()?; + let goose_meta = tool_result.meta.as_ref()?.0.get("goose")?.clone(); + let mut meta_map = serde_json::Map::new(); + meta_map.insert("goose".to_string(), goose_meta); + Some(meta_map) +} + fn build_tool_call_content(tool_result: &ToolResult) -> Vec { match tool_result { Ok(result) => result @@ -1627,6 +1693,9 @@ impl GooseAcpAgent { .client_fs_capabilities .set(args.client_capabilities.fs.clone()); let _ = self.client_terminal.set(args.client_capabilities.terminal); + let _ = self + .client_mcp_host_info + .set(extract_client_mcp_host_info(&args)); let capabilities = AgentCapabilities::new() .load_session(true) @@ -2084,12 +2153,12 @@ impl GooseAcpAgent { } } + let update = + ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields) + .meta(extract_tool_call_update_meta(tool_response)); cx.send_notification(SessionNotification::new( args.session_id.clone(), - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - ToolCallId::new(tool_response.id.clone()), - fields, - )), + SessionUpdate::ToolCallUpdate(update), ))?; } MessageContent::Thinking(thinking) => { @@ -3962,6 +4031,7 @@ pub async fn run(builtins: Vec) -> Result<()> { builtins, data_dir: Paths::data_dir(), config_dir: Paths::config_dir(), + goose_platform: GoosePlatform::GooseCli, }, ); let agent = server.create_agent().await?; diff --git a/crates/goose/src/acp/server_factory.rs b/crates/goose/src/acp/server_factory.rs index dbbc9d1a074f..2e9686a708a9 100644 --- a/crates/goose/src/acp/server_factory.rs +++ b/crates/goose/src/acp/server_factory.rs @@ -1,4 +1,5 @@ use crate::acp::server::{AcpProviderFactory, GooseAcpAgent}; +use crate::agents::GoosePlatform; use anyhow::Result; use std::sync::Arc; use tracing::info; @@ -7,6 +8,7 @@ pub struct AcpServerFactoryConfig { pub builtins: Vec, pub data_dir: std::path::PathBuf, pub config_dir: std::path::PathBuf, + pub goose_platform: GoosePlatform, } pub struct AcpServer { @@ -44,6 +46,7 @@ impl AcpServer { self.config.config_dir.clone(), goose_mode, disable_session_naming, + self.config.goose_platform.clone(), ) .await?; info!("Created new ACP agent"); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 990cfa20d495..57d8da542c91 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use super::container::Container; use super::final_output_tool::FinalOutputTool; +use super::mcp_client::GooseMcpHostInfo; use super::platform_tools; use super::tool_confirmation_router::ToolConfirmationRouter; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; @@ -114,6 +115,7 @@ pub struct AgentConfig { pub goose_mode: GooseMode, pub disable_session_naming: bool, pub goose_platform: GoosePlatform, + pub mcp_host_info: Option, } impl AgentConfig { @@ -132,8 +134,14 @@ impl AgentConfig { goose_mode, disable_session_naming, goose_platform, + mcp_host_info: None, } } + + pub fn with_mcp_host_info(mut self, mcp_host_info: Option) -> Self { + self.mcp_host_info = mcp_host_info; + self + } } /// The main goose Agent @@ -223,10 +231,23 @@ impl Agent { let goose_platform = config.goose_platform.clone(); let initial_mode = config.goose_mode; - let capabilities = match config.goose_platform { - GoosePlatform::GooseDesktop => ExtensionManagerCapabilities { mcpui: true }, - GoosePlatform::GooseCli => ExtensionManagerCapabilities { mcpui: false }, + let explicit_mcp_host_info = config.mcp_host_info.clone(); + let mcpui = explicit_mcp_host_info + .as_ref() + .filter(|host_info| host_info.explicit_extensions) + .map(GooseMcpHostInfo::mcpui_enabled) + .unwrap_or_else(|| match config.goose_platform { + GoosePlatform::GooseDesktop => true, + GoosePlatform::GooseCli => false, + }); + let capabilities = ExtensionManagerCapabilities { + mcpui, + host_info: explicit_mcp_host_info.clone(), }; + let client_name = explicit_mcp_host_info + .as_ref() + .and_then(|host_info| host_info.client_name.clone()) + .unwrap_or_else(|| goose_platform.to_string()); let session_manager = Arc::clone(&config.session_manager); let permission_manager = Arc::clone(&config.permission_manager); Self { @@ -236,7 +257,7 @@ impl Agent { extension_manager: Arc::new(ExtensionManager::new( provider.clone(), session_manager, - goose_platform.to_string(), + client_name, capabilities, )), final_output_tool: Arc::new(Mutex::new(None)), diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index e534c0a3b827..29ffadf188ae 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -34,7 +34,9 @@ use super::tool_execution::{ToolCallContext, ToolCallResult}; use super::types::SharedProvider; use crate::agents::extension::{Envs, ProcessExit}; use crate::agents::extension_malware_check; -use crate::agents::mcp_client::{GooseMcpClientCapabilities, McpClient, McpClientTrait}; +use crate::agents::mcp_client::{ + GooseMcpClientCapabilities, GooseMcpHostInfo, McpClient, McpClientTrait, +}; use crate::builtin_extension::get_builtin_extension; use crate::config::extensions::name_to_key; use crate::config::search_path::SearchPaths; @@ -43,8 +45,8 @@ use crate::oauth::oauth_flow; use crate::prompt_template; use crate::subprocess::configure_subprocess; use rmcp::model::{ - CallToolRequestParams, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Resource, - ResourceContents, ServerInfo, Tool, + CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData, GetPromptResult, Meta, + Prompt, Resource, ResourceContents, ServerInfo, Tool, }; use rmcp::transport::auth::AuthClient; use schemars::_private::NoSerialize; @@ -116,6 +118,21 @@ impl Extension { pub struct ExtensionManagerCapabilities { pub mcpui: bool, + pub host_info: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GooseMcpAppToolAttachment { + pub tool_name: String, + pub extension_name: String, + pub resource_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_meta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_error: Option, } /// Manages goose extensions / MCP clients and their interactions @@ -211,6 +228,42 @@ pub fn get_tool_owner(tool: &Tool) -> Option { .map(|s| s.to_string()) } +fn get_tool_meta_value(tool: &Tool) -> Option { + tool.meta.as_ref().map(|meta| Value::Object(meta.0.clone())) +} + +fn get_tool_resource_uri(tool: &Tool) -> Option { + tool.meta + .as_ref() + .and_then(|meta| meta.0.get("ui")) + .and_then(Value::as_object) + .and_then(|ui| ui.get("resourceUri")) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn merge_mcp_app_attachment_meta( + result: &mut CallToolResult, + attachment: &GooseMcpAppToolAttachment, +) { + let mut meta_map = result + .meta + .as_ref() + .map(|meta| meta.0.clone()) + .unwrap_or_default(); + let mut goose_value = meta_map + .remove("goose") + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + + goose_value.insert( + "mcpApp".to_string(), + serde_json::to_value(attachment).unwrap_or(Value::Null), + ); + meta_map.insert("goose".to_string(), Value::Object(goose_value)); + result.meta = Some(Meta(meta_map)); +} + fn is_unprefixed_extension(config: &ExtensionConfig) -> bool { match config { ExtensionConfig::Platform { name, .. } | ExtensionConfig::Builtin { name, .. } => { @@ -238,9 +291,12 @@ pub fn is_hidden_extension(name: &str) -> bool { /// Result of resolving a tool call to its owning extension struct ResolvedTool { + tool_name: String, extension_name: String, actual_tool_name: String, client: McpClientBox, + tool_meta: Option, + resource_uri: Option, } async fn child_process_client( @@ -591,6 +647,13 @@ async fn create_unix_socket_http_client( } impl ExtensionManager { + fn mcp_client_capabilities(&self) -> GooseMcpClientCapabilities { + GooseMcpClientCapabilities { + mcpui: self.capabilities.mcpui, + host_info: self.capabilities.host_info.clone(), + } + } + pub fn new( provider: SharedProvider, session_manager: Arc, @@ -618,7 +681,10 @@ impl ExtensionManager { Arc::new(Mutex::new(None)), session_manager, "goose-cli".to_string(), - ExtensionManagerCapabilities { mcpui: false }, + ExtensionManagerCapabilities { + mcpui: false, + host_info: None, + }, ) } @@ -697,10 +763,6 @@ impl ExtensionManager { .map(|(k, v)| (k.clone(), substitute_env_vars(v, &all_envs))) .collect(); let resolved_socket = socket.as_ref().map(|s| substitute_env_vars(s, &all_envs)); - let capability = GooseMcpClientCapabilities { - mcpui: self.capabilities.mcpui, - }; - create_streamable_http_client( &resolved_uri, *timeout, @@ -709,7 +771,7 @@ impl ExtensionManager { resolved_socket.as_deref(), self.provider.clone(), self.client_name.clone(), - capability, + self.mcp_client_capabilities(), &effective_working_dir, ) .await? @@ -760,10 +822,6 @@ impl ExtensionManager { .arg(&normalized_name); }); - let capabilities = GooseMcpClientCapabilities { - mcpui: self.capabilities.mcpui, - }; - let client = child_process_client( command, &Some(timeout_secs), @@ -771,7 +829,7 @@ impl ExtensionManager { &effective_working_dir, Some(container_id.to_string()), self.client_name.clone(), - capabilities, + self.mcp_client_capabilities(), ) .await?; Box::new(client) @@ -780,17 +838,13 @@ impl ExtensionManager { let (client_read, server_write) = tokio::io::duplex(65536); extension_fn(server_read, server_write); - let capabilities = GooseMcpClientCapabilities { - mcpui: self.capabilities.mcpui, - }; - Box::new( McpClient::connect( (client_read, client_write), Duration::from_secs(timeout_secs), self.provider.clone(), self.client_name.clone(), - capabilities, + self.mcp_client_capabilities(), effective_working_dir.clone(), ) .await?, @@ -840,9 +894,6 @@ impl ExtensionManager { }) }; - let capabilities = GooseMcpClientCapabilities { - mcpui: self.capabilities.mcpui, - }; let client = child_process_client( command, timeout, @@ -850,7 +901,7 @@ impl ExtensionManager { &effective_working_dir, container.map(|c| c.id().to_string()), self.client_name.clone(), - capabilities, + self.mcp_client_capabilities(), ) .await?; Box::new(client) @@ -875,10 +926,6 @@ impl ExtensionManager { command.arg("python").arg(file_path.to_str().unwrap()); }); - let capabilities = GooseMcpClientCapabilities { - mcpui: self.capabilities.mcpui, - }; - let client = child_process_client( command, timeout, @@ -886,7 +933,7 @@ impl ExtensionManager { &effective_working_dir, container.map(|c| c.id().to_string()), self.client_name.clone(), - capabilities, + self.mcp_client_capabilities(), ) .await?; @@ -1069,6 +1116,55 @@ impl ExtensionManager { Ok(tools) } + fn host_supports_mcp_apps(&self) -> bool { + if let Some(host_info) = &self.capabilities.host_info { + if host_info.explicit_extensions { + return host_info.mcpui_enabled(); + } + } + + self.capabilities.mcpui + } + + async fn hydrate_mcp_app_attachment( + client: &McpClientBox, + session_id: &str, + resolved_tool: &ResolvedTool, + cancellation_token: CancellationToken, + result: &mut CallToolResult, + ) { + if result.is_error == Some(true) { + return; + } + + let Some(resource_uri) = resolved_tool.resource_uri.clone() else { + return; + }; + + let mut attachment = GooseMcpAppToolAttachment { + tool_name: resolved_tool.tool_name.clone(), + extension_name: resolved_tool.extension_name.clone(), + resource_uri: resource_uri.clone(), + tool_meta: resolved_tool.tool_meta.clone(), + resource_result: None, + read_error: None, + }; + + match client + .read_resource(session_id, &resource_uri, cancellation_token) + .await + { + Ok(resource_result) => { + attachment.resource_result = serde_json::to_value(&resource_result).ok(); + } + Err(error) => { + attachment.read_error = Some(error.to_string()); + } + } + + merge_mcp_app_attachment_meta(result, &attachment); + } + async fn invalidate_tools_cache_and_bump_version(&self) { self.tools_cache_version.fetch_add(1, Ordering::SeqCst); *self.tools_cache.lock().await = None; @@ -1438,17 +1534,6 @@ impl ExtensionManager { session_id: &str, tool_name: &str, ) -> Result { - if let Some((prefix, actual)) = tool_name.split_once("__") { - let owner = name_to_key(prefix); - if let Some(client) = self.get_server_client(&owner).await { - return Ok(ResolvedTool { - extension_name: owner, - actual_tool_name: actual.to_string(), - client, - }); - } - } - let tools = self.get_all_tools_cached(session_id).await.map_err(|e| { ErrorData::new( ErrorCode::INTERNAL_ERROR, @@ -1458,13 +1543,19 @@ impl ExtensionManager { })?; if let Some(tool) = tools.iter().find(|t| *t.name == *tool_name) { - let owner = get_tool_owner(tool).ok_or_else(|| { - ErrorData::new( - ErrorCode::RESOURCE_NOT_FOUND, - format!("Tool '{}' has no owner", tool_name), - None, - ) - })?; + let owner = get_tool_owner(tool) + .or_else(|| { + tool_name + .split_once("__") + .map(|(prefix, _)| name_to_key(prefix)) + }) + .ok_or_else(|| { + ErrorData::new( + ErrorCode::RESOURCE_NOT_FOUND, + format!("Tool '{}' has no owner", tool_name), + None, + ) + })?; let actual_tool_name = tool_name .strip_prefix(&format!("{owner}__")) @@ -1480,12 +1571,29 @@ impl ExtensionManager { })?; return Ok(ResolvedTool { + tool_name: tool.name.to_string(), extension_name: owner, actual_tool_name, client, + tool_meta: get_tool_meta_value(tool), + resource_uri: get_tool_resource_uri(tool), }); } + if let Some((prefix, actual)) = tool_name.split_once("__") { + let owner = name_to_key(prefix); + if let Some(client) = self.get_server_client(&owner).await { + return Ok(ResolvedTool { + tool_name: tool_name.to_string(), + extension_name: owner, + actual_tool_name: actual.to_string(), + client, + tool_meta: None, + resource_uri: None, + }); + } + } + Err(ErrorData::new( ErrorCode::RESOURCE_NOT_FOUND, format!("Tool '{}' not found", tool_name), @@ -1521,8 +1629,13 @@ impl ExtensionManager { let arguments = tool_call.arguments.clone(); let client = resolved.client.clone(); + let hydration_client = client.clone(); let notifications_receiver = client.subscribe().await; - let actual_tool_name = resolved.actual_tool_name; + let actual_tool_name = resolved.actual_tool_name.clone(); + let resolved_tool = resolved; + let should_hydrate_mcp_app = self.host_supports_mcp_apps(); + let read_cancellation_token = cancellation_token.clone(); + let session_id = ctx.session_id.clone(); let owned_ctx = ToolCallContext::new( ctx.session_id.clone(), ctx.working_dir.clone(), @@ -1536,7 +1649,7 @@ impl ExtensionManager { owned_ctx.session_id, owned_ctx.working_dir, ); - client + let mut result = client .call_tool(&owned_ctx, &actual_tool_name, arguments, cancellation_token) .await .map_err(|e| match e { @@ -1544,7 +1657,20 @@ impl ExtensionManager { _ => { ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), e.maybe_to_value()) } - }) + })?; + + if should_hydrate_mcp_app { + Self::hydrate_mcp_app_attachment( + &hydration_client, + &session_id, + &resolved_tool, + read_cancellation_token, + &mut result, + ) + .await; + } + + Ok(result) }; Ok(ToolCallResult { diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index aa604d2dd393..a0a7ac8fc16e 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -37,6 +37,34 @@ pub type BoxError = Box; pub type Error = rmcp::ServiceError; +const MCP_APPS_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; +const MCP_APPS_UI_MIME_TYPE: &str = "text/html;profile=mcp-app"; + +fn default_mcp_apps_ui_extensions() -> ExtensionCapabilities { + let mut extensions = ExtensionCapabilities::new(); + let mut ui_extension_settings = JsonObject::new(); + ui_extension_settings.insert( + "mimeTypes".to_string(), + serde_json::json!([MCP_APPS_UI_MIME_TYPE]), + ); + extensions.insert(MCP_APPS_UI_EXTENSION_ID.to_string(), ui_extension_settings); + extensions +} + +#[derive(Debug, Clone, Default)] +pub struct GooseMcpHostInfo { + pub explicit_extensions: bool, + pub extensions: ExtensionCapabilities, + pub client_name: Option, + pub client_version: Option, +} + +impl GooseMcpHostInfo { + pub fn mcpui_enabled(&self) -> bool { + self.extensions.contains_key(MCP_APPS_UI_EXTENSION_ID) + } +} + #[async_trait::async_trait] pub trait McpClientTrait: Send + Sync { async fn list_tools( @@ -164,6 +192,40 @@ impl GooseClient { .and_then(|(_, value)| value.as_str()) .map(|value| value.to_string()) } + + fn resolved_extensions(&self) -> ExtensionCapabilities { + if let Some(host_info) = &self.capabilities.host_info { + if host_info.explicit_extensions { + return host_info.extensions.clone(); + } + } + + if self.capabilities.mcpui { + return default_mcp_apps_ui_extensions(); + } + + ExtensionCapabilities::new() + } + + fn resolved_client_info(&self) -> Implementation { + let name = self + .capabilities + .host_info + .as_ref() + .and_then(|host_info| host_info.client_name.clone()) + .unwrap_or_else(|| self.client_name.clone()); + let version = self + .capabilities + .host_info + .as_ref() + .and_then(|host_info| host_info.client_version.clone()) + .unwrap_or_else(|| { + std::env::var("GOOSE_MCP_CLIENT_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION").to_owned()) + }); + + Implementation::new(name, version) + } } fn working_dir_roots(dir: &std::path::Path) -> ListRootsResult { @@ -340,21 +402,7 @@ impl ClientHandler for GooseClient { } fn get_info(&self) -> ClientInfo { - let mut extensions = ExtensionCapabilities::new(); - - if self.capabilities.mcpui { - // Build MCP Apps UI extension capability - // See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx - let mut ui_extension_settings = JsonObject::new(); - ui_extension_settings.insert( - "mimeTypes".to_string(), - serde_json::json!(["text/html;profile=mcp-app"]), - ); - extensions.insert( - "io.modelcontextprotocol/ui".to_string(), - ui_extension_settings, - ); - } + let extensions = self.resolved_extensions(); InitializeRequestParams::new( ClientCapabilities::builder() @@ -363,11 +411,7 @@ impl ClientHandler for GooseClient { .enable_sampling() .enable_elicitation() .build(), - Implementation::new( - self.client_name.clone(), - std::env::var("GOOSE_MCP_CLIENT_VERSION") - .unwrap_or(env!("CARGO_PKG_VERSION").to_owned()), - ), + self.resolved_client_info(), ) .with_protocol_version(ProtocolVersion::V_2025_03_26) } @@ -376,6 +420,7 @@ impl ClientHandler for GooseClient { #[derive(Debug, Clone)] pub struct GooseMcpClientCapabilities { pub mcpui: bool, + pub host_info: Option, } /// The MCP client is the interface for MCP operations. @@ -769,8 +814,14 @@ mod tests { fn new_client(platform: GoosePlatform) -> GooseClient { let capabilities = match platform { - GoosePlatform::GooseDesktop => GooseMcpClientCapabilities { mcpui: true }, - GoosePlatform::GooseCli => GooseMcpClientCapabilities { mcpui: false }, + GoosePlatform::GooseDesktop => GooseMcpClientCapabilities { + mcpui: true, + host_info: None, + }, + GoosePlatform::GooseCli => GooseMcpClientCapabilities { + mcpui: false, + host_info: None, + }, }; GooseClient::new( @@ -1000,6 +1051,93 @@ mod tests { ); } + #[test] + fn test_explicit_host_info_passes_through_client_identity() { + let client = GooseClient::new( + Arc::new(Mutex::new(Vec::new())), + Arc::new(Mutex::new(None)), + GoosePlatform::GooseDesktop.to_string(), + GooseMcpClientCapabilities { + mcpui: true, + host_info: Some(GooseMcpHostInfo { + explicit_extensions: true, + extensions: ExtensionCapabilities::new(), + client_name: Some("goose2".to_string()), + client_version: Some("0.1.0".to_string()), + }), + }, + std::env::current_dir().unwrap_or_default(), + ); + + let info = ClientHandler::get_info(&client); + assert_eq!(info.client_info.name, "goose2"); + assert_eq!(info.client_info.version, "0.1.0"); + let extensions = info + .capabilities + .extensions + .expect("client should still serialize an extensions object"); + assert!( + !extensions.contains_key(MCP_APPS_UI_EXTENSION_ID), + "explicit empty host extensions should disable platform fallback" + ); + } + + #[test] + fn test_explicit_host_extensions_override_platform_fallback() { + let client = GooseClient::new( + Arc::new(Mutex::new(Vec::new())), + Arc::new(Mutex::new(None)), + GoosePlatform::GooseCli.to_string(), + GooseMcpClientCapabilities { + mcpui: false, + host_info: Some(GooseMcpHostInfo { + explicit_extensions: true, + extensions: default_mcp_apps_ui_extensions(), + client_name: Some("goose2".to_string()), + client_version: Some("0.1.0".to_string()), + }), + }, + std::env::current_dir().unwrap_or_default(), + ); + + let info = ClientHandler::get_info(&client); + let extensions = info + .capabilities + .extensions + .expect("capabilities should have explicit host extensions"); + + assert!(extensions.contains_key(MCP_APPS_UI_EXTENSION_ID)); + assert_eq!(info.client_info.name, "goose2"); + } + + #[test] + fn test_host_identity_does_not_disable_platform_fallback_without_explicit_extensions() { + let client = GooseClient::new( + Arc::new(Mutex::new(Vec::new())), + Arc::new(Mutex::new(None)), + GoosePlatform::GooseDesktop.to_string(), + GooseMcpClientCapabilities { + mcpui: true, + host_info: Some(GooseMcpHostInfo { + explicit_extensions: false, + extensions: ExtensionCapabilities::new(), + client_name: Some("goose2".to_string()), + client_version: Some("0.1.0".to_string()), + }), + }, + std::env::current_dir().unwrap_or_default(), + ); + + let info = ClientHandler::get_info(&client); + let extensions = info + .capabilities + .extensions + .expect("platform fallback should still advertise MCP Apps UI"); + + assert!(extensions.contains_key(MCP_APPS_UI_EXTENSION_ID)); + assert_eq!(info.client_info.name, "goose2"); + } + #[test] fn test_working_dir_roots_returns_current_dir_as_root() { let dir = PathBuf::from("/tmp/test-project"); diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index ab763edce984..082ae8166459 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use fs_err as fs; use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent}; pub use goose::acp::{map_permission_response, PermissionDecision}; +use goose::agents::GoosePlatform; use goose::builtin_extension::register_builtin_extensions; use goose::config::paths::Paths; use goose::config::{GooseMode, PermissionManager}; @@ -190,6 +191,7 @@ pub async fn spawn_acp_server_in_process( data_root.to_path_buf(), goose_mode, true, + GoosePlatform::GooseCli, ) .await .unwrap(); diff --git a/crates/goose/tests/mcp_integration_test.rs b/crates/goose/tests/mcp_integration_test.rs index 693f31f9418f..b85931c44a76 100644 --- a/crates/goose/tests/mcp_integration_test.rs +++ b/crates/goose/tests/mcp_integration_test.rs @@ -261,7 +261,10 @@ async fn test_replayed_session( provider, session_manager, GoosePlatform::GooseDesktop.to_string(), - ExtensionManagerCapabilities { mcpui: true }, + ExtensionManagerCapabilities { + mcpui: true, + host_info: None, + }, )); #[allow(clippy::redundant_closure_call)] diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index f676deb1405e..df6b435556fd 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -88,16 +88,19 @@ export const ConfigProvider: React.FC = ({ children }) => { [reloadConfig] ); - const read = useCallback(async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => { - const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; - const response = await readConfig({ - body: query, - }); - if (options?.throwOnError && response.error) { - throw response.error; - } - return response.data; - }, []); + const read = useCallback( + async (key: string, is_secret: boolean = false, options?: { throwOnError?: boolean }) => { + const query: ConfigKeyQuery = { key: key, is_secret: is_secret }; + const response = await readConfig({ + body: query, + }); + if (options?.throwOnError && response.error) { + throw response.error; + } + return response.data; + }, + [] + ); const remove = useCallback( async (key: string, is_secret: boolean) => { diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 6ad2559c5055..17eecae4726c 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -68,7 +68,9 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
{intl.formatMessage(i18n.addProvider)}
-
{intl.formatMessage(i18n.fromTemplateOrManual)}
+
+ {intl.formatMessage(i18n.fromTemplateOrManual)} +
} @@ -277,7 +279,9 @@ function ProviderCards({ const editable = editingProvider ? editingProvider.isEditable : true; const title = editingProvider - ? (editable ? intl.formatMessage(i18n.editProvider) : intl.formatMessage(i18n.configureProvider)) + ? editable + ? intl.formatMessage(i18n.editProvider) + : intl.formatMessage(i18n.configureProvider) : intl.formatMessage(i18n.addProviderTitle); return ( <> diff --git a/ui/goose2/src/features/chat/hooks/replayBuffer.ts b/ui/goose2/src/features/chat/hooks/replayBuffer.ts index d9af08a2262b..967f93ec43cc 100644 --- a/ui/goose2/src/features/chat/hooks/replayBuffer.ts +++ b/ui/goose2/src/features/chat/hooks/replayBuffer.ts @@ -35,6 +35,10 @@ export function getBufferedMessage( return replayBuffers.get(sessionId)?.find((m) => m.id === messageId); } +export function getReplayBuffer(sessionId: string): Message[] | undefined { + return replayBuffers.get(sessionId); +} + export function getAndDeleteReplayBuffer( sessionId: string, ): Message[] | undefined { diff --git a/ui/goose2/src/features/chat/ui/McpAppView.tsx b/ui/goose2/src/features/chat/ui/McpAppView.tsx new file mode 100644 index 000000000000..1ae853f9574c --- /dev/null +++ b/ui/goose2/src/features/chat/ui/McpAppView.tsx @@ -0,0 +1,22 @@ +import { CodeBlock } from "@/shared/ui/ai-elements/code-block"; +import { useTranslation } from "react-i18next"; +import type { McpAppPayload } from "@/shared/types/messages"; + +interface McpAppViewProps { + payload: McpAppPayload; +} + +export function McpAppView({ payload }: McpAppViewProps) { + const { t } = useTranslation("chat"); + + // 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")} +
+ +
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index ce1d3c74575d..a72af28a2d6b 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -32,6 +32,7 @@ import { } from "@/shared/ui/ai-elements/reasoning"; import { ToolChainCards, type ToolChainItem } from "./ToolChainCards"; import { ClickableImage } from "./ClickableImage"; +import { McpAppView } from "./McpAppView"; import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler"; import type { Message, @@ -97,10 +98,9 @@ interface ContentSection { items: MessageContent[] | ToolChainItem[]; } -/** Keep only content blocks whose audience includes "user" (or has no audience). */ function filterUserVisibleContent(content: MessageContent[]): MessageContent[] { return content.filter((b) => { - const aud = b.annotations?.audience; + const aud = "annotations" in b ? b.annotations?.audience : undefined; return !aud || aud.length === 0 || aud.includes("user"); }); } @@ -232,6 +232,8 @@ function renderContentBlock( case "toolResponse": // Handled by groupContentSections toolChain rendering return null; + case "mcpApp": + return ; case "thinking": case "reasoning": { const text = (content as ThinkingContent | ReasoningContentType).text; diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.mcpApp.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.mcpApp.test.tsx new file mode 100644 index 000000000000..f05586d23c5c --- /dev/null +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.mcpApp.test.tsx @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MessageBubble } from "../MessageBubble"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import type { Message } from "@/shared/types/messages"; + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openPath: vi.fn(), +})); + +function assistantMessage( + content: Message["content"], + overrides: Partial = {}, +): Message { + return { + id: "a1", + role: "assistant", + created: Date.now(), + content, + ...overrides, + }; +} + +describe("MessageBubble MCP app rendering", () => { + beforeEach(() => { + useAgentStore.setState({ personas: [] }); + }); + + it("renders MCP App blocks", () => { + const msg = assistantMessage([ + { + type: "toolRequest", + id: "tool-1", + name: "weather: open app", + arguments: {}, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-1", + name: "weather: open app", + result: "done", + isError: false, + }, + { + type: "mcpApp", + id: "tool-1", + payload: { + sessionId: "local-session", + gooseSessionId: "goose-session", + toolCallId: "tool-1", + toolCallTitle: "weather: open app", + source: "toolCallUpdateMeta", + tool: { + name: "weather__open_app", + extensionName: "weather", + resourceUri: "ui://weather/app", + }, + resource: { + result: { + contents: [ + { + uri: "ui://weather/app", + mimeType: "text/html", + text: "
Hello
", + }, + ], + }, + }, + }, + }, + ]); + + render(); + + const mcpAppView = screen.getByTestId("mcp-app-view"); + expect(mcpAppView).toBeInTheDocument(); + expect(mcpAppView).toHaveTextContent("ui://weather/app"); + expect(mcpAppView).toHaveTextContent("
Hello
"); + }); +}); diff --git a/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts new file mode 100644 index 000000000000..89fabcb3b4b7 --- /dev/null +++ b/ui/goose2/src/shared/api/__tests__/acpNotificationHandler.test.ts @@ -0,0 +1,293 @@ +import { waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearReplayBuffer, + getReplayBuffer, +} from "@/features/chat/hooks/replayBuffer"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import type { McpAppPayload } from "@/shared/types/messages"; +import { + clearMessageTracking, + handleSessionNotification, + setActiveMessageId, +} from "../acpNotificationHandler"; +import { registerSession } from "../acpSessionTracker"; + +function createMcpAppPayload(): McpAppPayload { + return { + sessionId: "local-session", + gooseSessionId: "goose-session", + toolCallId: "tool-1", + toolCallTitle: "mcp_app_bench__inspect_host_info", + source: "toolCallUpdateMeta", + tool: { + name: "mcp_app_bench__inspect_host_info", + extensionName: "mcp_app_bench", + resourceUri: "ui://inspect-host-info", + }, + resource: { + result: null, + }, + }; +} + +describe("acpNotificationHandler", () => { + beforeEach(() => { + clearMessageTracking(); + clearReplayBuffer("local-session"); + clearReplayBuffer("goose-session"); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + queuedMessageBySession: {}, + draftsBySession: {}, + activeSessionId: null, + isConnected: false, + loadingSessionIds: new Set(), + scrollTargetMessageBySession: {}, + }); + }); + + it("keeps tool calls that arrive before the first text chunk on the pending assistant message", async () => { + registerSession( + "local-session", + "goose-session", + "goose", + "/Users/aharvard/.goose/artifacts", + ); + setActiveMessageId("goose-session", "assistant-1"); + + await handleSessionNotification({ + sessionId: "goose-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "mcp_app_bench__inspect_host_info", + }, + } as never); + + await handleSessionNotification({ + sessionId: "goose-session", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + content: [ + { + type: "content", + content: { + type: "text", + text: "Opened the Host Info inspector.", + }, + }, + ], + _meta: { + goose: { + mcpApp: { + toolName: "mcp_app_bench__inspect_host_info", + extensionName: "mcp_app_bench", + resourceUri: "ui://inspect-host-info", + }, + }, + }, + }, + } as never); + + await handleSessionNotification({ + sessionId: "goose-session", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "The Host Info inspector is now open.", + }, + }, + } as never); + + await waitFor(() => { + const message = + useChatStore.getState().messagesBySession["local-session"]?.[0]; + expect(message?.content.some((block) => block.type === "mcpApp")).toBe( + true, + ); + }); + + const [message] = + useChatStore.getState().messagesBySession["local-session"]; + expect(message.id).toBe("assistant-1"); + expect(message.content.map((block) => block.type)).toEqual([ + "toolRequest", + "toolResponse", + "mcpApp", + "text", + ]); + expect(message.content[0]).toMatchObject({ + type: "toolRequest", + id: "tool-1", + name: "mcp_app_bench__inspect_host_info", + status: "completed", + }); + expect(message.content[1]).toMatchObject({ + type: "toolResponse", + id: "tool-1", + name: "mcp_app_bench__inspect_host_info", + result: "Opened the Host Info inspector.", + isError: false, + }); + expect(message.content[2]).toMatchObject({ + type: "mcpApp", + id: "tool-1", + payload: createMcpAppPayload(), + }); + expect(message.content[3]).toMatchObject({ + type: "text", + text: "The Host Info inspector is now open.", + }); + expect( + useChatStore.getState().getSessionRuntime("local-session") + .streamingMessageId, + ).toBe("assistant-1"); + }); + + it("replay keeps tool and MCP app content on an assistant message when tool events arrive before text", async () => { + const replaySessionId = "replay-goose-session"; + useChatStore.setState({ + loadingSessionIds: new Set([replaySessionId]), + }); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "user_message_chunk", + messageId: "user-1", + content: { + type: "text", + text: "run the app bench", + }, + }, + } as never); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "mcp_app_bench__inspect_host_info", + }, + } as never); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + content: [ + { + type: "content", + content: { + type: "text", + text: "Opened the Host Info inspector.", + }, + }, + ], + _meta: { + goose: { + mcpApp: { + toolName: "mcp_app_bench__inspect_host_info", + extensionName: "mcp_app_bench", + resourceUri: "ui://inspect-host-info", + }, + }, + }, + }, + } as never); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: "assistant-1", + content: { + type: "text", + text: "The Host Info inspector is now open.", + }, + }, + } as never); + + const buffer = getReplayBuffer(replaySessionId); + expect(buffer).toHaveLength(2); + expect(buffer?.[0]).toMatchObject({ + id: "user-1", + role: "user", + content: [{ type: "text", text: "run the app bench" }], + }); + expect( + buffer?.[0]?.content.some((block) => block.type === "toolRequest"), + ).toBe(false); + + expect(buffer?.[1]?.id).toBe("assistant-1"); + expect(buffer?.[1]?.role).toBe("assistant"); + expect(buffer?.[1]?.content.map((block) => block.type)).toEqual([ + "toolRequest", + "toolResponse", + "mcpApp", + "text", + ]); + expect(buffer?.[1]?.content[2]).toMatchObject({ + type: "mcpApp", + id: "tool-1", + payload: { + ...createMcpAppPayload(), + sessionId: replaySessionId, + gooseSessionId: replaySessionId, + }, + }); + }); + + it("replay preserves gooseSessionId in MCP app payloads before tracker registration", async () => { + const replaySessionId = "replay-goose-session-2"; + useChatStore.setState({ + loadingSessionIds: new Set([replaySessionId]), + }); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "mcp_app_bench__inspect_host_info", + }, + } as never); + + await handleSessionNotification({ + sessionId: replaySessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + _meta: { + goose: { + mcpApp: { + toolName: "mcp_app_bench__inspect_host_info", + extensionName: "mcp_app_bench", + resourceUri: "ui://inspect-host-info", + }, + }, + }, + }, + } as never); + + const buffer = getReplayBuffer(replaySessionId); + const assistant = buffer?.[0]; + const mcpAppBlock = assistant?.content.find( + (block) => block.type === "mcpApp", + ); + expect(mcpAppBlock).toMatchObject({ + type: "mcpApp", + payload: expect.objectContaining({ + gooseSessionId: replaySessionId, + }), + }); + }); +}); diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 04900340da10..4506f48c58a3 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -97,17 +97,19 @@ export async function acpSendMessage( const tPrompt = performance.now(); const meta: Record = {}; if (personaId) meta.personaId = personaId; - await directAcp.prompt( - gooseSessionId, - content, - Object.keys(meta).length > 0 ? meta : undefined, - ); - const tDone = performance.now(); - perfLog( - `[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`, - ); - - clearActiveMessageId(gooseSessionId); + try { + await directAcp.prompt( + gooseSessionId, + content, + Object.keys(meta).length > 0 ? meta : undefined, + ); + const tDone = performance.now(); + perfLog( + `[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`, + ); + } finally { + clearActiveMessageId(gooseSessionId); + } } /** Prepare or warm an ACP session ahead of the first prompt. */ diff --git a/ui/goose2/src/shared/api/acpConnection.ts b/ui/goose2/src/shared/api/acpConnection.ts index 5a369d3d8c14..e9cfa909230d 100644 --- a/ui/goose2/src/shared/api/acpConnection.ts +++ b/ui/goose2/src/shared/api/acpConnection.ts @@ -1,5 +1,9 @@ import { invoke } from "@tauri-apps/api/core"; -import { GooseClient } from "@aaif/goose-sdk"; +import { + DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + GooseClient, + type GooseInitializeRequest, +} from "@aaif/goose-sdk"; import { PROTOCOL_VERSION, type Client, @@ -7,6 +11,7 @@ import { type RequestPermissionRequest, type RequestPermissionResponse, } from "@agentclientprotocol/sdk"; +import packageJson from "../../../package.json"; import { createWebSocketStream } from "./createWebSocketStream"; import { perfLog } from "@/shared/lib/perfLog"; @@ -81,12 +86,18 @@ async function initializeConnection(): Promise { const tInit = performance.now(); await client.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: {}, + clientCapabilities: { + _meta: { + goose: { + mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, + }, + }, + }, clientInfo: { - name: "goose2", - version: "0.1.0", + name: packageJson.name, + version: packageJson.version, }, - }); + } satisfies GooseInitializeRequest); perfLog( `[perf:conn] client.initialize in ${(performance.now() - tInit).toFixed(1)}ms (total ${(performance.now() - tStart).toFixed(1)}ms)`, ); diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/acpNotificationHandler.test.ts index c2acbc887865..ee6442d3bc07 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.test.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.test.ts @@ -6,10 +6,14 @@ import { getAndDeleteReplayBuffer, } from "@/features/chat/hooks/replayBuffer"; import { registerSession } from "./acpSessionTracker"; -import { handleSessionNotification } from "./acpNotificationHandler"; +import { + clearMessageTracking, + handleSessionNotification, +} from "./acpNotificationHandler"; describe("acpNotificationHandler", () => { beforeEach(() => { + clearMessageTracking(); clearReplayBuffer("draft-session-1"); clearReplayBuffer("draft-session-2"); useChatStore.setState({ diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts index dd19ac0036cd..f0936598d096 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts @@ -15,6 +15,17 @@ import type { ToolResponseContent, } from "@/shared/types/messages"; import type { AcpNotificationHandler } from "./acpConnection"; +import { + attachMcpAppPayload, + extractToolResultText, + findReplayMessageWithToolCall, +} from "./acpToolCallContent"; +import { + clearReplayAssistantMessage, + clearReplayAssistantTracking, + ensureReplayAssistantMessage, + getTrackedReplayAssistantMessageId, +} from "./acpReplayAssistant"; import { getLocalSessionId, subscribeToSessionRegistration, @@ -23,47 +34,6 @@ import { perfLog } from "@/shared/lib/perfLog"; // Pre-set message ID for the next live stream per goose session const presetMessageIds = new Map(); -const pendingUsageUpdates = new Map(); - -function shouldBufferPendingUpdate(update: SessionUpdate): boolean { - return update.sessionUpdate === "usage_update"; -} - -function queuePendingUsageUpdate( - gooseSessionId: string, - update: SessionUpdate, -): void { - const pending = pendingUsageUpdates.get(gooseSessionId); - if (pending) { - pending.push(update); - return; - } - pendingUsageUpdates.set(gooseSessionId, [update]); -} - -function flushPendingUsageUpdates( - localSessionId: string, - gooseSessionId: string, -): void { - const pending = pendingUsageUpdates.get(gooseSessionId); - if (!pending?.length) { - return; - } - - pendingUsageUpdates.delete(gooseSessionId); - - for (const update of pending) { - if (useChatStore.getState().loadingSessionIds.has(localSessionId)) { - handleReplay(localSessionId, update); - } else { - handleLive(localSessionId, gooseSessionId, update); - } - } -} - -subscribeToSessionRegistration((localSessionId, gooseSessionId) => { - flushPendingUsageUpdates(localSessionId, gooseSessionId); -}); // Per-session perf counters for replay/live streaming. interface ReplayPerf { @@ -78,6 +48,20 @@ interface LivePerf { chunkCount: number; } const livePerf = new Map(); +const pendingUsageUpdates = new Map< + string, + { accumulatedTotal: number; contextLimit: number } +>(); + +subscribeToSessionRegistration((localSessionId, gooseSessionId) => { + const pendingUsage = pendingUsageUpdates.get(gooseSessionId); + if (!pendingUsage) { + return; + } + + useChatStore.getState().updateTokenState(localSessionId, pendingUsage); + pendingUsageUpdates.delete(gooseSessionId); +}); export function setActiveMessageId( gooseSessionId: string, @@ -112,32 +96,23 @@ export async function handleSessionNotification( notification: SessionNotification, ): Promise { const gooseSessionId = notification.sessionId; - const { update } = notification; const localSessionId = getLocalSessionId(gooseSessionId); - - if (!localSessionId) { - if (shouldBufferPendingUpdate(update)) { - queuePendingUsageUpdate(gooseSessionId, update); - } - return; - } - - const isReplay = useChatStore - .getState() - .loadingSessionIds.has(localSessionId); + const sessionId = localSessionId ?? gooseSessionId; + const { update } = notification; + const isReplay = useChatStore.getState().loadingSessionIds.has(sessionId); if (isReplay) { - const sid = localSessionId.slice(0, 8); - let perf = replayPerf.get(localSessionId); + const sid = sessionId.slice(0, 8); + let perf = replayPerf.get(sessionId); const now = performance.now(); if (!perf) { perf = { firstAt: now, lastAt: now, count: 0 }; - replayPerf.set(localSessionId, perf); + replayPerf.set(sessionId, perf); perfLog(`[perf:replay] ${sid} first notification received`); } perf.lastAt = now; perf.count += 1; - handleReplay(localSessionId, update); + handleReplay(sessionId, gooseSessionId, localSessionId, update); } else { const perf = livePerf.get(gooseSessionId); if (perf && update.sessionUpdate === "agent_message_chunk") { @@ -150,7 +125,7 @@ export async function handleSessionNotification( ); } } - handleLive(localSessionId, gooseSessionId, update); + handleLive(sessionId, gooseSessionId, localSessionId, update); } } @@ -166,25 +141,18 @@ export function clearReplayPerf(sessionId: string): void { replayPerf.delete(sessionId); } -function handleReplay(sessionId: string, update: SessionUpdate): void { +function handleReplay( + sessionId: string, + gooseSessionId: string, + localSessionId: string | null, + update: SessionUpdate, +): void { switch (update.sessionUpdate) { case "agent_message_chunk": { - const messageId = update.messageId ?? crypto.randomUUID(); - const buffer = ensureReplayBuffer(sessionId); - if (!getBufferedMessage(sessionId, messageId)) { - buffer.push({ - id: messageId, - role: "assistant", - created: Date.now(), - content: [], - metadata: { - userVisible: true, - agentVisible: true, - completionStatus: "inProgress", - }, - }); - } - const msg = getBufferedMessage(sessionId, messageId); + const msg = ensureReplayAssistantMessage( + sessionId, + update.messageId ?? null, + ); if (msg && update.content.type === "text" && "text" in update.content) { const last = msg.content[msg.content.length - 1]; if (last?.type === "text") { @@ -197,6 +165,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { } case "user_message_chunk": { + clearReplayAssistantMessage(sessionId); if (update.content.type !== "text" || !("text" in update.content)) break; const messageId = update.messageId ?? crypto.randomUUID(); const buffer = ensureReplayBuffer(sessionId); @@ -228,22 +197,25 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { } case "tool_call": { - const msg = findMessageInBuffer(sessionId, update.toolCallId); - if (msg) { - msg.content.push({ - type: "toolRequest", - id: update.toolCallId, - name: update.title, - arguments: {}, - status: "executing", - startedAt: Date.now(), - }); - } + const msg = ensureReplayAssistantMessage(sessionId); + msg.content.push({ + type: "toolRequest", + id: update.toolCallId, + name: update.title, + arguments: {}, + status: "executing", + startedAt: Date.now(), + }); break; } case "tool_call_update": { - const msg = findMessageWithToolCall(sessionId, update.toolCallId); + const replayMessageId = getTrackedReplayAssistantMessageId(sessionId); + const msg = + findReplayMessageWithToolCall(sessionId, update.toolCallId) ?? + (replayMessageId + ? getBufferedMessage(sessionId, replayMessageId) + : undefined); if (msg) { if (update.title) { const tc = msg.content.find( @@ -274,6 +246,19 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { result: resultText, isError: update.status === "failed", }); + if (update.status === "completed") { + attachMcpAppPayload( + sessionId, + update.toolCallId, + (tc as ToolRequestContent)?.name ?? update.title ?? "", + update, + true, + { + gooseSessionId, + replayMessageId, + }, + ); + } } } break; @@ -282,7 +267,7 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { case "session_info_update": case "config_option_update": case "usage_update": - handleShared(sessionId, update); + handleShared(sessionId, gooseSessionId, localSessionId, update); break; default: @@ -293,36 +278,19 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { function handleLive( sessionId: string, gooseSessionId: string, + localSessionId: string | null, update: SessionUpdate, ): void { const store = useChatStore.getState(); switch (update.sessionUpdate) { case "agent_message_chunk": { - const messageId = - update.messageId ?? - presetMessageIds.get(gooseSessionId) ?? - crypto.randomUUID(); - const existing = store.messagesBySession[sessionId]?.find( - (m) => m.id === messageId, + const messageId = ensureLiveAssistantMessage( + sessionId, + gooseSessionId, + update.messageId, ); - if (!existing) { - store.addMessage(sessionId, { - id: messageId, - role: "assistant", - created: Date.now(), - content: [], - metadata: { - userVisible: true, - agentVisible: true, - completionStatus: "inProgress", - }, - }); - store.setPendingAssistantProvider(sessionId, null); - store.setStreamingMessageId(sessionId, messageId); - } - if (update.content.type === "text" && "text" in update.content) { store.setStreamingMessageId(sessionId, messageId); store.updateStreamingText(sessionId, update.content.text); @@ -331,8 +299,7 @@ function handleLive( } case "tool_call": { - const messageId = findStreamingMessageId(sessionId); - if (!messageId) break; + const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId); const toolRequest: ToolRequestContent = { type: "toolRequest", @@ -348,8 +315,7 @@ function handleLive( } case "tool_call_update": { - const messageId = findStreamingMessageId(sessionId); - if (!messageId) break; + const messageId = ensureLiveAssistantMessage(sessionId, gooseSessionId); if (update.title) { store.updateMessage(sessionId, messageId, (msg) => ({ @@ -389,6 +355,15 @@ function handleLive( }; store.setStreamingMessageId(sessionId, messageId); store.appendToStreamingMessage(sessionId, toolResponse); + if (update.status === "completed") { + attachMcpAppPayload( + sessionId, + update.toolCallId, + toolRequest?.name ?? update.title ?? "", + update, + false, + ); + } } break; } @@ -396,7 +371,7 @@ function handleLive( case "session_info_update": case "config_option_update": case "usage_update": - handleShared(sessionId, update); + handleShared(sessionId, gooseSessionId, localSessionId, update); break; default: @@ -404,7 +379,12 @@ function handleLive( } } -function handleShared(sessionId: string, update: SessionUpdate): void { +function handleShared( + sessionId: string, + gooseSessionId: string, + localSessionId: string | null, + update: SessionUpdate, +): void { switch (update.sessionUpdate) { case "session_info_update": { const info = update as SessionUpdate & { @@ -463,7 +443,16 @@ function handleShared(sessionId: string, update: SessionUpdate): void { case "usage_update": { const usage = update as SessionUpdate & { sessionUpdate: "usage_update" }; - useChatStore.getState().updateTokenState(sessionId, { + + if (!localSessionId) { + pendingUsageUpdates.set(gooseSessionId, { + accumulatedTotal: usage.used, + contextLimit: usage.size, + }); + break; + } + + useChatStore.getState().updateTokenState(localSessionId, { accumulatedTotal: usage.used, contextLimit: usage.size, }); @@ -475,8 +464,6 @@ function handleShared(sessionId: string, update: SessionUpdate): void { } } -// Helpers - function findStreamingMessageId(sessionId: string): string | null { return useChatStore.getState().getSessionRuntime(sessionId) .streamingMessageId; @@ -489,52 +476,53 @@ function makeTextBlock( return { type: "text", text, ...(ann ? { annotations: ann } : {}) }; } -function findMessageInBuffer( +function ensureLiveAssistantMessage( sessionId: string, - _toolCallId: string, -): ReturnType { - const buffer = ensureReplayBuffer(sessionId); - return buffer[buffer.length - 1]; -} - -function findMessageWithToolCall( - sessionId: string, - toolCallId: string, -): ReturnType { - const buffer = ensureReplayBuffer(sessionId); - for (let i = buffer.length - 1; i >= 0; i--) { - const msg = buffer[i]; - if ( - msg.content.some((c) => c.type === "toolRequest" && c.id === toolCallId) - ) { - return msg; - } + gooseSessionId: string, + preferredMessageId?: string | null, +): string { + const store = useChatStore.getState(); + const existingStreamingMessageId = findStreamingMessageId(sessionId); + const messages = store.messagesBySession[sessionId] ?? []; + + if ( + existingStreamingMessageId && + messages.some((message) => message.id === existingStreamingMessageId) + ) { + return existingStreamingMessageId; } - return buffer[buffer.length - 1]; -} -function extractToolResultText(update: { - // biome-ignore lint/suspicious/noExplicitAny: ACP SDK ToolCallContent type is complex - content?: Array | null; - rawOutput?: unknown; -}): string { - if (update.content && update.content.length > 0) { - for (const item of update.content) { - if (item.type === "content" && item.content?.type === "text") { - return item.content.text; - } - } - } - if (update.rawOutput !== undefined && update.rawOutput !== null) { - return typeof update.rawOutput === "string" - ? update.rawOutput - : JSON.stringify(update.rawOutput); + const messageId = + preferredMessageId ?? + presetMessageIds.get(gooseSessionId) ?? + existingStreamingMessageId ?? + crypto.randomUUID(); + + if (!messages.some((message) => message.id === messageId)) { + store.addMessage(sessionId, { + id: messageId, + role: "assistant", + created: Date.now(), + content: [], + metadata: { + userVisible: true, + agentVisible: true, + completionStatus: "inProgress", + }, + }); } - return ""; + + store.setPendingAssistantProvider(sessionId, null); + store.setStreamingMessageId(sessionId, messageId); + clearActiveMessageId(gooseSessionId); + + return messageId; } export function clearMessageTracking(): void { presetMessageIds.clear(); + pendingUsageUpdates.clear(); + clearReplayAssistantTracking(); } const handler: AcpNotificationHandler = { diff --git a/ui/goose2/src/shared/api/acpReplayAssistant.ts b/ui/goose2/src/shared/api/acpReplayAssistant.ts new file mode 100644 index 000000000000..811a8bbcacdc --- /dev/null +++ b/ui/goose2/src/shared/api/acpReplayAssistant.ts @@ -0,0 +1,64 @@ +import { + ensureReplayBuffer, + getBufferedMessage, +} from "@/features/chat/hooks/replayBuffer"; +import type { Message } from "@/shared/types/messages"; + +const replayAssistantMessageIds = new Map(); + +export function getTrackedReplayAssistantMessageId( + sessionId: string, +): string | null { + return replayAssistantMessageIds.get(sessionId) ?? null; +} + +export function ensureReplayAssistantMessage( + sessionId: string, + preferredMessageId?: string | null, +): Message { + const trackedMessageId = replayAssistantMessageIds.get(sessionId); + + if (preferredMessageId) { + const preferredMessage = getBufferedMessage(sessionId, preferredMessageId); + if (preferredMessage?.role === "assistant") { + replayAssistantMessageIds.set(sessionId, preferredMessageId); + return preferredMessage; + } + } + + if (trackedMessageId) { + const trackedMessage = getBufferedMessage(sessionId, trackedMessageId); + if (trackedMessage?.role === "assistant") { + if (preferredMessageId && trackedMessage.id !== preferredMessageId) { + trackedMessage.id = preferredMessageId; + replayAssistantMessageIds.set(sessionId, preferredMessageId); + } + return trackedMessage; + } + } + + const messageId = preferredMessageId ?? crypto.randomUUID(); + const buffer = ensureReplayBuffer(sessionId); + const message: Message = { + id: messageId, + role: "assistant", + created: Date.now(), + content: [], + metadata: { + userVisible: true, + agentVisible: true, + completionStatus: "inProgress", + }, + }; + buffer.push(message); + replayAssistantMessageIds.set(sessionId, messageId); + return message; +} + +export function clearReplayAssistantMessage(sessionId: string): void { + replayAssistantMessageIds.delete(sessionId); +} + +export function clearReplayAssistantTracking(): void { + replayAssistantMessageIds.clear(); +} diff --git a/ui/goose2/src/shared/api/acpToolCallContent.ts b/ui/goose2/src/shared/api/acpToolCallContent.ts new file mode 100644 index 000000000000..8f60567c21a6 --- /dev/null +++ b/ui/goose2/src/shared/api/acpToolCallContent.ts @@ -0,0 +1,150 @@ +import type { SessionUpdate } from "@agentclientprotocol/sdk"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { + getReplayBuffer, + getBufferedMessage, +} from "@/features/chat/hooks/replayBuffer"; +import type { McpAppContent, MessageContent } from "@/shared/types/messages"; +import { buildMcpAppPayloadFromToolUpdate } from "./mcpAppToolUpdate"; + +export function findReplayMessageWithToolCall( + sessionId: string, + toolCallId: string, +): ReturnType { + const buffer = getReplayBuffer(sessionId); + if (!buffer) { + return undefined; + } + for (let index = buffer.length - 1; index >= 0; index -= 1) { + const message = buffer[index]; + if ( + message.content.some( + (content) => + content.type === "toolRequest" && content.id === toolCallId, + ) + ) { + return message; + } + } + return undefined; +} + +export function extractToolResultText(update: { + // biome-ignore lint/suspicious/noExplicitAny: ACP SDK ToolCallContent type is complex + content?: Array | null; + rawOutput?: unknown; +}): string { + if (update.content && update.content.length > 0) { + for (const item of update.content) { + if (item.type === "content" && item.content?.type === "text") { + return item.content.text; + } + } + } + if (update.rawOutput !== undefined && update.rawOutput !== null) { + return typeof update.rawOutput === "string" + ? update.rawOutput + : JSON.stringify(update.rawOutput); + } + return ""; +} + +export function attachMcpAppPayload( + sessionId: string, + toolCallId: string, + toolCallTitle: string, + update: SessionUpdate, + isReplay: boolean, + options?: { + gooseSessionId?: string | null; + replayMessageId?: string | null; + }, +): void { + const payload = buildMcpAppPayloadFromToolUpdate( + sessionId, + toolCallId, + toolCallTitle, + update, + options?.gooseSessionId, + ); + if (!payload) { + return; + } + + const block: McpAppContent = { + type: "mcpApp", + id: toolCallId, + payload, + }; + + if (isReplay) { + const message = + findReplayMessageWithToolCall(sessionId, toolCallId) ?? + (options?.replayMessageId + ? getBufferedMessage(sessionId, options.replayMessageId) + : undefined); + if (message) { + message.content = insertMcpAppContent(message.content, block); + return; + } + } + + const store = useChatStore.getState(); + const message = [...(store.messagesBySession[sessionId] ?? [])] + .reverse() + .find((candidate) => + candidate.content.some( + (content) => + content.type === "toolRequest" && content.id === toolCallId, + ), + ); + if (!message) { + return; + } + + store.updateMessage(sessionId, message.id, (current) => ({ + ...current, + content: insertMcpAppContent(current.content, block), + })); +} + +function insertMcpAppContent( + content: MessageContent[], + block: McpAppContent, +): MessageContent[] { + if (content.some((item) => item.type === "mcpApp" && item.id === block.id)) { + return content; + } + + const insertAfterIndex = findMcpAppAnchorIndex(content, block.id); + if (insertAfterIndex === -1) { + return [...content, block]; + } + + return [ + ...content.slice(0, insertAfterIndex + 1), + block, + ...content.slice(insertAfterIndex + 1), + ]; +} + +function findMcpAppAnchorIndex( + content: MessageContent[], + toolCallId: string, +): number { + for (let index = content.length - 1; index >= 0; index -= 1) { + const block = content[index]; + if (block.type === "toolResponse" && block.id === toolCallId) { + return index; + } + } + + for (let index = content.length - 1; index >= 0; index -= 1) { + const block = content[index]; + if (block.type === "toolRequest" && block.id === toolCallId) { + return index; + } + } + + return -1; +} diff --git a/ui/goose2/src/shared/api/mcpAppToolUpdate.ts b/ui/goose2/src/shared/api/mcpAppToolUpdate.ts new file mode 100644 index 000000000000..125cc0a85324 --- /dev/null +++ b/ui/goose2/src/shared/api/mcpAppToolUpdate.ts @@ -0,0 +1,62 @@ +import type { + GooseMcpAppToolPayload, + GooseReadResourceResult, + GooseToolCallUpdateMeta, + GooseToolMetadata, +} from "@aaif/goose-sdk"; +import type { SessionUpdate } from "@agentclientprotocol/sdk"; +import type { McpAppPayload } from "@/shared/types/messages"; +import { getGooseSessionId } from "./acpSessionTracker"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function extractMcpAppPayload( + update: SessionUpdate, +): GooseMcpAppToolPayload | null { + if (update.sessionUpdate !== "tool_call_update" || !isRecord(update._meta)) { + return null; + } + + const meta = update._meta as GooseToolCallUpdateMeta; + const payload = meta.goose?.mcpApp; + return isRecord(payload) ? (payload as GooseMcpAppToolPayload) : null; +} + +export function buildMcpAppPayloadFromToolUpdate( + sessionId: string, + toolCallId: string, + toolCallTitle: string, + update: SessionUpdate, + gooseSessionIdOverride?: string | null, +): McpAppPayload | null { + const payload = extractMcpAppPayload(update); + if (!payload) { + return null; + } + + return { + sessionId, + gooseSessionId: + gooseSessionIdOverride ?? getGooseSessionId(sessionId) ?? null, + toolCallId, + toolCallTitle, + source: "toolCallUpdateMeta", + tool: { + name: payload.toolName, + extensionName: payload.extensionName, + resourceUri: payload.resourceUri, + meta: isRecord(payload.toolMeta) + ? (payload.toolMeta as GooseToolMetadata) + : undefined, + }, + resource: { + result: + (payload.resourceResult as GooseReadResourceResult | null) ?? null, + ...(typeof payload.readError === "string" + ? { readError: payload.readError } + : {}), + }, + }; +} diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index dca7bef470d2..1a0bdfcd740a 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -126,6 +126,7 @@ "message": { "copied": "Copied", "defaultImageAlt": "Attached", + "mcpAppUnderConstruction": "🚧 MCP App Rendering is under construction", "redactedThinking": "(thinking redacted)" }, "persona": { diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index ee6bd1678337..c11e9df401a8 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -126,6 +126,7 @@ "message": { "copied": "Copiado", "defaultImageAlt": "Adjunto", + "mcpAppUnderConstruction": "🚧 La renderización de MCP App está en construcción", "redactedThinking": "(pensamiento redactado)" }, "persona": { diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts index d557c06bdec1..ba4d434effcd 100644 --- a/ui/goose2/src/shared/types/messages.ts +++ b/ui/goose2/src/shared/types/messages.ts @@ -1,3 +1,8 @@ +import type { + GooseReadResourceResult, + GooseToolMetadata, +} from "@aaif/goose-sdk"; + export type ChatAttachmentKind = "image" | "file" | "directory"; export interface ChatImageAttachmentDraft { @@ -89,6 +94,30 @@ export interface ToolResponseContent { annotations?: ContentAnnotations; } +export interface McpAppPayload { + sessionId: string; + gooseSessionId: string | null; + toolCallId: string; + toolCallTitle: string; + source: "toolCallUpdateMeta"; + tool: { + name: string; + extensionName: string; + resourceUri: string; + meta?: GooseToolMetadata; + }; + resource: { + result: GooseReadResourceResult | null; + readError?: string; + }; +} + +export interface McpAppContent { + type: "mcpApp"; + id: string; + payload: McpAppPayload; +} + export interface ThinkingContent { type: "thinking"; text: string; @@ -129,6 +158,7 @@ export type MessageContent = | ImageContent | ToolRequestContent | ToolResponseContent + | McpAppContent | ThinkingContent | RedactedThinkingContent | ReasoningContent @@ -181,6 +211,9 @@ export function isToolRequest(c: MessageContent): c is ToolRequestContent { export function isToolResponse(c: MessageContent): c is ToolResponseContent { return c.type === "toolResponse"; } +export function isMcpApp(c: MessageContent): c is McpAppContent { + return c.type === "mcpApp"; +} export function isThinking(c: MessageContent): c is ThinkingContent { return c.type === "thinking"; } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 231b3dd8f9ce..6f2eb75d56e8 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -648,6 +648,12 @@ importers: sdk: dependencies: + '@modelcontextprotocol/ext-apps': + specifier: ^0.3.1 + version: 0.3.1(@modelcontextprotocol/sdk@1.27.1(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@3.25.76) + '@modelcontextprotocol/sdk': + specifier: ^1.27.0 + version: 1.27.1(zod@3.25.76) zod: specifier: ^3.25.76 version: 3.25.76 diff --git a/ui/sdk/package.json b/ui/sdk/package.json index 91ae373f396e..fb7fc0645b32 100644 --- a/ui/sdk/package.json +++ b/ui/sdk/package.json @@ -40,6 +40,8 @@ "format": "prettier --write src/" }, "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/sdk": "^1.27.0", "zod": "^3.25.76" }, "peerDependencies": { diff --git a/ui/sdk/src/index.ts b/ui/sdk/src/index.ts index 9e4f469d2430..5e587ed92e89 100644 --- a/ui/sdk/src/index.ts +++ b/ui/sdk/src/index.ts @@ -2,6 +2,7 @@ export * from "./generated/types.gen.js"; export * from "./generated/zod.gen.js"; export { GooseClient } from "./goose-client.js"; export { createHttpStream } from "./http-stream.js"; +export * from "./mcp-apps.js"; export { ClientSideConnection, diff --git a/ui/sdk/src/mcp-apps.ts b/ui/sdk/src/mcp-apps.ts new file mode 100644 index 000000000000..03f3be6ef551 --- /dev/null +++ b/ui/sdk/src/mcp-apps.ts @@ -0,0 +1,90 @@ +import type { + Implementation, + InitializeRequest, +} from "@agentclientprotocol/sdk"; +import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { + McpUiAppResourceConfig, + McpUiAppToolConfig, +} from "@modelcontextprotocol/ext-apps/server"; +import type { + BlobResourceContents, + ReadResourceResult, + TextResourceContents, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; + +export const GOOSE_MCP_UI_EXTENSION_ID = "io.modelcontextprotocol/ui" as const; + +export interface GooseMcpUiExtensionSettings { + mimeTypes: string[]; +} + +export interface GooseMcpHostCapabilities { + extensions: Record; +} + +export type GooseToolUiMetadata = Extract< + McpUiAppToolConfig["_meta"], + { ui: unknown } +>["ui"]; + +export type GooseToolMetadata = NonNullable & { + ui?: GooseToolUiMetadata; + goose_extension?: string; +}; + +export type GooseSessionTool = Tool & { + meta?: GooseToolMetadata; + _meta?: GooseToolMetadata; +}; + +export type GooseTextResourceContents = TextResourceContents; + +export type GooseBlobResourceContents = BlobResourceContents; + +export type GooseResourceContents = TextResourceContents | BlobResourceContents; + +export type GooseReadResourceResult = ReadResourceResult; + +export type GooseResourceMetadata = NonNullable< + Extract, { ui?: unknown }>["ui"] +>; + +export interface GooseMcpAppToolPayload { + toolName: string; + extensionName: string; + resourceUri: string; + toolMeta?: GooseToolMetadata; + resourceResult?: GooseReadResourceResult | null; + readError?: string; +} + +export interface GooseToolCallUpdateMeta { + goose?: { + mcpApp?: GooseMcpAppToolPayload; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface GooseClientMeta { + goose: { + mcpHostCapabilities: GooseMcpHostCapabilities; + }; +} + +export type GooseInitializeRequest = InitializeRequest & { + clientCapabilities: NonNullable & { + _meta: GooseClientMeta; + }; + clientInfo: Implementation; +}; + +export const DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: GooseMcpHostCapabilities = { + extensions: { + [GOOSE_MCP_UI_EXTENSION_ID]: { + mimeTypes: [RESOURCE_MIME_TYPE], + }, + }, +};