[goose2] MCP Apps: hydrate and replay app payloads in Goose2 (#8632)
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
This commit is contained in:
@@ -3,6 +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::extension_manager::TRUSTED_TOOL_UPDATE_META_KEY;
|
||||
use crate::agents::mcp_client::{GooseMcpHostInfo, McpClientTrait};
|
||||
use crate::agents::platform_extensions::developer::DeveloperClient;
|
||||
use crate::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig};
|
||||
@@ -1532,12 +1533,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(())
|
||||
@@ -1629,6 +1629,21 @@ fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConf
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tool_call_update_meta(
|
||||
tool_response: &crate::conversation::message::ToolResponse,
|
||||
) -> Option<Meta> {
|
||||
let tool_result = tool_response.tool_result.as_ref().ok()?;
|
||||
let goose_meta = tool_result
|
||||
.meta
|
||||
.as_ref()?
|
||||
.0
|
||||
.get(TRUSTED_TOOL_UPDATE_META_KEY)?
|
||||
.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<CallToolResult>) -> Vec<ToolCallContent> {
|
||||
match tool_result {
|
||||
Ok(result) => result
|
||||
@@ -2144,12 +2159,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) => {
|
||||
@@ -4409,6 +4424,49 @@ print(\"hello, world\")
|
||||
.map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_update_meta_ignores_untrusted_goose_meta() {
|
||||
let response = response_with_meta(Some(serde_json::json!({
|
||||
"goose": {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/app",
|
||||
},
|
||||
},
|
||||
})));
|
||||
|
||||
assert_eq!(extract_tool_call_update_meta(&response), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_call_update_meta_uses_trusted_meta_only() {
|
||||
let response = response_with_meta(Some(serde_json::json!({
|
||||
"goose": {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/app",
|
||||
},
|
||||
},
|
||||
TRUSTED_TOOL_UPDATE_META_KEY: {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://trusted/app",
|
||||
"extensionName": "weather",
|
||||
"toolName": "weather__render",
|
||||
},
|
||||
},
|
||||
})));
|
||||
|
||||
let extracted = extract_tool_call_update_meta(&response).expect("expected trusted meta");
|
||||
assert_eq!(
|
||||
extracted.get("goose"),
|
||||
Some(&serde_json::json!({
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://trusted/app",
|
||||
"extensionName": "weather",
|
||||
"toolName": "weather__render",
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
fn make_session_with_usage(
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
|
||||
@@ -45,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;
|
||||
@@ -121,6 +121,22 @@ pub struct ExtensionManagerCapabilities {
|
||||
pub host_info: Option<GooseMcpHostInfo>,
|
||||
}
|
||||
|
||||
#[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<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_error: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) const TRUSTED_TOOL_UPDATE_META_KEY: &str = "__goose_tool_update_meta";
|
||||
|
||||
/// Manages goose extensions / MCP clients and their interactions
|
||||
pub struct ExtensionManager {
|
||||
extensions: Mutex<HashMap<String, Extension>>,
|
||||
@@ -214,6 +230,68 @@ pub fn get_tool_owner(tool: &Tool) -> Option<String> {
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn get_tool_meta_value(tool: &Tool) -> Option<Value> {
|
||||
tool.meta.as_ref().map(|meta| Value::Object(meta.0.clone()))
|
||||
}
|
||||
|
||||
fn get_tool_resource_uri(tool: &Tool) -> Option<String> {
|
||||
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 remove_untrusted_mcp_app_meta(result: &mut CallToolResult) {
|
||||
let Some(meta) = result.meta.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
meta.0.remove(TRUSTED_TOOL_UPDATE_META_KEY);
|
||||
|
||||
let remove_goose = meta
|
||||
.0
|
||||
.get_mut("goose")
|
||||
.and_then(Value::as_object_mut)
|
||||
.map(|goose_meta| {
|
||||
goose_meta.remove("mcpApp");
|
||||
goose_meta.is_empty()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if remove_goose {
|
||||
meta.0.remove("goose");
|
||||
}
|
||||
|
||||
if meta.0.is_empty() {
|
||||
result.meta = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_trusted_tool_update_meta(
|
||||
result: &mut CallToolResult,
|
||||
attachment: &GooseMcpAppToolAttachment,
|
||||
) {
|
||||
let Ok(attachment_value) = serde_json::to_value(attachment) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut meta_map = result
|
||||
.meta
|
||||
.as_ref()
|
||||
.map(|meta| meta.0.clone())
|
||||
.unwrap_or_default();
|
||||
let mut trusted_meta = serde_json::Map::new();
|
||||
trusted_meta.insert("mcpApp".to_string(), attachment_value);
|
||||
meta_map.insert(
|
||||
TRUSTED_TOOL_UPDATE_META_KEY.to_string(),
|
||||
Value::Object(trusted_meta),
|
||||
);
|
||||
result.meta = Some(Meta(meta_map));
|
||||
}
|
||||
|
||||
fn is_unprefixed_extension(config: &ExtensionConfig) -> bool {
|
||||
match config {
|
||||
ExtensionConfig::Platform { name, .. } | ExtensionConfig::Builtin { name, .. } => {
|
||||
@@ -241,9 +319,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<Value>,
|
||||
resource_uri: Option<String>,
|
||||
}
|
||||
|
||||
async fn child_process_client(
|
||||
@@ -1063,6 +1144,48 @@ 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,
|
||||
) -> Option<GooseMcpAppToolAttachment> {
|
||||
let resource_uri = resolved_tool.resource_uri.clone()?;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Some(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;
|
||||
@@ -1432,17 +1555,6 @@ impl ExtensionManager {
|
||||
session_id: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<ResolvedTool, ErrorData> {
|
||||
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,
|
||||
@@ -1452,13 +1564,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}__"))
|
||||
@@ -1474,12 +1592,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),
|
||||
@@ -1515,8 +1650,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(),
|
||||
@@ -1530,7 +1670,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 {
|
||||
@@ -1538,7 +1678,24 @@ impl ExtensionManager {
|
||||
_ => {
|
||||
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), e.maybe_to_value())
|
||||
}
|
||||
})
|
||||
})?;
|
||||
|
||||
remove_untrusted_mcp_app_meta(&mut result);
|
||||
|
||||
if should_hydrate_mcp_app && result.is_error != Some(true) {
|
||||
if let Some(attachment) = Self::hydrate_mcp_app_attachment(
|
||||
&hydration_client,
|
||||
&session_id,
|
||||
&resolved_tool,
|
||||
read_cancellation_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
insert_trusted_tool_update_meta(&mut result, &attachment);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
};
|
||||
|
||||
Ok(ToolCallResult {
|
||||
@@ -2318,6 +2475,80 @@ mod tests {
|
||||
assert!(!tool_names.iter().any(|n| n.starts_with("ext_b__")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_untrusted_mcp_app_meta_strips_spoofed_payload() {
|
||||
let mut result = CallToolResult::success(vec![]);
|
||||
result.meta = Some(Meta(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"goose": {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/app",
|
||||
},
|
||||
"other": true,
|
||||
},
|
||||
TRUSTED_TOOL_UPDATE_META_KEY: {
|
||||
"mcpApp": {
|
||||
"resourceUri": "ui://spoofed/internal",
|
||||
},
|
||||
},
|
||||
}))
|
||||
.unwrap(),
|
||||
));
|
||||
|
||||
remove_untrusted_mcp_app_meta(&mut result);
|
||||
|
||||
let meta = result.meta.expect("expected remaining meta");
|
||||
assert_eq!(meta.0.get(TRUSTED_TOOL_UPDATE_META_KEY), None);
|
||||
assert_eq!(
|
||||
meta.0.get("goose"),
|
||||
Some(&serde_json::json!({ "other": true }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_trusted_tool_update_meta_stores_backend_payload() {
|
||||
let mut result = CallToolResult::success(vec![]);
|
||||
let attachment = GooseMcpAppToolAttachment {
|
||||
tool_name: "weather__render".to_string(),
|
||||
extension_name: "weather".to_string(),
|
||||
resource_uri: "ui://weather/app".to_string(),
|
||||
tool_meta: None,
|
||||
resource_result: Some(serde_json::json!({
|
||||
"contents": [
|
||||
{
|
||||
"uri": "ui://weather/app",
|
||||
"mimeType": "text/html;profile=mcp-app",
|
||||
"text": "<div>Hello</div>",
|
||||
},
|
||||
],
|
||||
})),
|
||||
read_error: None,
|
||||
};
|
||||
|
||||
insert_trusted_tool_update_meta(&mut result, &attachment);
|
||||
|
||||
let meta = result.meta.expect("expected trusted meta");
|
||||
assert_eq!(
|
||||
meta.0.get(TRUSTED_TOOL_UPDATE_META_KEY),
|
||||
Some(&serde_json::json!({
|
||||
"mcpApp": {
|
||||
"toolName": "weather__render",
|
||||
"extensionName": "weather",
|
||||
"resourceUri": "ui://weather/app",
|
||||
"resourceResult": {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "ui://weather/app",
|
||||
"mimeType": "text/html;profile=mcp-app",
|
||||
"text": "<div>Hello</div>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_extension_noop_on_identical_config() {
|
||||
// When add_extension is called with a config that is byte-for-byte identical to
|
||||
|
||||
Reference in New Issue
Block a user