[goose2] MCP Apps: translate ACP host capabilities into MCP initialization (#8623)

Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
Andrew Harvard
2026-04-28 08:03:44 -04:00
committed by GitHub
parent 52b3f21ef7
commit f908837407
13 changed files with 345 additions and 69 deletions
+25 -4
View File
@@ -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<GooseMcpHostInfo>,
}
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<GooseMcpHostInfo>) -> 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)),
+20 -26
View File
@@ -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;
@@ -116,6 +118,7 @@ impl Extension {
pub struct ExtensionManagerCapabilities {
pub mcpui: bool,
pub host_info: Option<GooseMcpHostInfo>,
}
/// Manages goose extensions / MCP clients and their interactions
@@ -591,6 +594,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<crate::session::SessionManager>,
@@ -618,7 +628,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 +710,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 +718,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 +769,6 @@ impl ExtensionManager {
.arg(&normalized_name);
});
let capabilities = GooseMcpClientCapabilities {
mcpui: self.capabilities.mcpui,
};
let client = child_process_client(
command,
&Some(timeout_secs),
@@ -771,7 +776,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 +785,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 +841,6 @@ impl ExtensionManager {
})
};
let capabilities = GooseMcpClientCapabilities {
mcpui: self.capabilities.mcpui,
};
let client = child_process_client(
command,
timeout,
@@ -850,7 +848,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 +873,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 +880,7 @@ impl ExtensionManager {
&effective_working_dir,
container.map(|c| c.id().to_string()),
self.client_name.clone(),
capabilities,
self.mcp_client_capabilities(),
)
.await?;
+160 -22
View File
@@ -37,6 +37,34 @@ pub type BoxError = Box<dyn std::error::Error + Sync + Send>;
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<String>,
pub client_version: Option<String>,
}
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<GooseMcpHostInfo>,
}
/// 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");