[goose2] MCP Apps: translate ACP host capabilities into MCP initialization (#8623)
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user