Skip to content

Commit 8a24b3c

Browse files
committed
hydrate and replay mcp app payloads in goose2
Signed-off-by: Andrew Harvard <aharvard@squareup.com>
1 parent 7a3aad5 commit 8a24b3c

17 files changed

Lines changed: 789 additions & 108 deletions

File tree

crates/goose-acp/src/server.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,12 +1194,11 @@ impl GooseAcpAgent {
11941194
}
11951195
}
11961196

1197+
let update = ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields)
1198+
.meta(extract_tool_call_update_meta(tool_response));
11971199
cx.send_notification(SessionNotification::new(
11981200
session_id.clone(),
1199-
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1200-
ToolCallId::new(tool_response.id.clone()),
1201-
fields,
1202-
)),
1201+
SessionUpdate::ToolCallUpdate(update),
12031202
))?;
12041203

12051204
Ok(())
@@ -1291,6 +1290,16 @@ fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConf
12911290
}
12921291
}
12931292

1293+
fn extract_tool_call_update_meta(
1294+
tool_response: &goose::conversation::message::ToolResponse,
1295+
) -> Option<Meta> {
1296+
let tool_result = tool_response.tool_result.as_ref().ok()?;
1297+
let goose_meta = tool_result.meta.as_ref()?.0.get("goose")?.clone();
1298+
let mut meta_map = serde_json::Map::new();
1299+
meta_map.insert("goose".to_string(), goose_meta);
1300+
Some(meta_map)
1301+
}
1302+
12941303
fn build_tool_call_content(tool_result: &ToolResult<CallToolResult>) -> Vec<ToolCallContent> {
12951304
match tool_result {
12961305
Ok(result) => result
@@ -1738,12 +1747,12 @@ impl GooseAcpAgent {
17381747
}
17391748
}
17401749

1750+
let update =
1751+
ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields)
1752+
.meta(extract_tool_call_update_meta(tool_response));
17411753
cx.send_notification(SessionNotification::new(
17421754
args.session_id.clone(),
1743-
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
1744-
ToolCallId::new(tool_response.id.clone()),
1745-
fields,
1746-
)),
1755+
SessionUpdate::ToolCallUpdate(update),
17471756
))?;
17481757
replay_notifications += 1;
17491758
}

crates/goose/src/agents/extension_manager.rs

Lines changed: 155 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ use crate::oauth::oauth_flow;
4545
use crate::prompt_template;
4646
use crate::subprocess::configure_subprocess;
4747
use rmcp::model::{
48-
CallToolRequestParams, Content, ErrorCode, ErrorData, GetPromptResult, Prompt, Resource,
49-
ResourceContents, ServerInfo, Tool,
48+
CallToolRequestParams, CallToolResult, Content, ErrorCode, ErrorData, GetPromptResult, Meta,
49+
Prompt, Resource, ResourceContents, ServerInfo, Tool,
5050
};
5151
use rmcp::transport::auth::AuthClient;
5252
use schemars::_private::NoSerialize;
@@ -121,6 +121,20 @@ pub struct ExtensionManagerCapabilities {
121121
pub host_info: Option<GooseMcpHostInfo>,
122122
}
123123

124+
#[derive(Debug, Clone, serde::Serialize)]
125+
#[serde(rename_all = "camelCase")]
126+
pub struct GooseMcpAppToolAttachment {
127+
pub tool_name: String,
128+
pub extension_name: String,
129+
pub resource_uri: String,
130+
#[serde(skip_serializing_if = "Option::is_none")]
131+
pub tool_meta: Option<Value>,
132+
#[serde(skip_serializing_if = "Option::is_none")]
133+
pub resource_result: Option<Value>,
134+
#[serde(skip_serializing_if = "Option::is_none")]
135+
pub read_error: Option<String>,
136+
}
137+
124138
/// Manages goose extensions / MCP clients and their interactions
125139
pub struct ExtensionManager {
126140
extensions: Mutex<HashMap<String, Extension>>,
@@ -214,6 +228,42 @@ pub fn get_tool_owner(tool: &Tool) -> Option<String> {
214228
.map(|s| s.to_string())
215229
}
216230

231+
fn get_tool_meta_value(tool: &Tool) -> Option<Value> {
232+
tool.meta.as_ref().map(|meta| Value::Object(meta.0.clone()))
233+
}
234+
235+
fn get_tool_resource_uri(tool: &Tool) -> Option<String> {
236+
tool.meta
237+
.as_ref()
238+
.and_then(|meta| meta.0.get("ui"))
239+
.and_then(Value::as_object)
240+
.and_then(|ui| ui.get("resourceUri"))
241+
.and_then(Value::as_str)
242+
.map(ToString::to_string)
243+
}
244+
245+
fn merge_mcp_app_attachment_meta(
246+
result: &mut CallToolResult,
247+
attachment: &GooseMcpAppToolAttachment,
248+
) {
249+
let mut meta_map = result
250+
.meta
251+
.as_ref()
252+
.map(|meta| meta.0.clone())
253+
.unwrap_or_default();
254+
let mut goose_value = meta_map
255+
.remove("goose")
256+
.and_then(|value| value.as_object().cloned())
257+
.unwrap_or_default();
258+
259+
goose_value.insert(
260+
"mcpApp".to_string(),
261+
serde_json::to_value(attachment).unwrap_or(Value::Null),
262+
);
263+
meta_map.insert("goose".to_string(), Value::Object(goose_value));
264+
result.meta = Some(Meta(meta_map));
265+
}
266+
217267
fn is_unprefixed_extension(config: &ExtensionConfig) -> bool {
218268
match config {
219269
ExtensionConfig::Platform { name, .. } | ExtensionConfig::Builtin { name, .. } => {
@@ -241,9 +291,12 @@ pub fn is_hidden_extension(name: &str) -> bool {
241291

242292
/// Result of resolving a tool call to its owning extension
243293
struct ResolvedTool {
294+
tool_name: String,
244295
extension_name: String,
245296
actual_tool_name: String,
246297
client: McpClientBox,
298+
tool_meta: Option<Value>,
299+
resource_uri: Option<String>,
247300
}
248301

249302
async fn child_process_client(
@@ -1064,6 +1117,55 @@ impl ExtensionManager {
10641117
Ok(tools)
10651118
}
10661119

1120+
fn host_supports_mcp_apps(&self) -> bool {
1121+
if let Some(host_info) = &self.capabilities.host_info {
1122+
if host_info.explicit_extensions {
1123+
return host_info.mcpui_enabled();
1124+
}
1125+
}
1126+
1127+
self.capabilities.mcpui
1128+
}
1129+
1130+
async fn hydrate_mcp_app_attachment(
1131+
client: &McpClientBox,
1132+
session_id: &str,
1133+
resolved_tool: &ResolvedTool,
1134+
cancellation_token: CancellationToken,
1135+
result: &mut CallToolResult,
1136+
) {
1137+
if result.is_error == Some(true) {
1138+
return;
1139+
}
1140+
1141+
let Some(resource_uri) = resolved_tool.resource_uri.clone() else {
1142+
return;
1143+
};
1144+
1145+
let mut attachment = GooseMcpAppToolAttachment {
1146+
tool_name: resolved_tool.tool_name.clone(),
1147+
extension_name: resolved_tool.extension_name.clone(),
1148+
resource_uri: resource_uri.clone(),
1149+
tool_meta: resolved_tool.tool_meta.clone(),
1150+
resource_result: None,
1151+
read_error: None,
1152+
};
1153+
1154+
match client
1155+
.read_resource(session_id, &resource_uri, cancellation_token)
1156+
.await
1157+
{
1158+
Ok(resource_result) => {
1159+
attachment.resource_result = serde_json::to_value(&resource_result).ok();
1160+
}
1161+
Err(error) => {
1162+
attachment.read_error = Some(error.to_string());
1163+
}
1164+
}
1165+
1166+
merge_mcp_app_attachment_meta(result, &attachment);
1167+
}
1168+
10671169
async fn invalidate_tools_cache_and_bump_version(&self) {
10681170
self.tools_cache_version.fetch_add(1, Ordering::SeqCst);
10691171
*self.tools_cache.lock().await = None;
@@ -1433,17 +1535,6 @@ impl ExtensionManager {
14331535
session_id: &str,
14341536
tool_name: &str,
14351537
) -> Result<ResolvedTool, ErrorData> {
1436-
if let Some((prefix, actual)) = tool_name.split_once("__") {
1437-
let owner = name_to_key(prefix);
1438-
if let Some(client) = self.get_server_client(&owner).await {
1439-
return Ok(ResolvedTool {
1440-
extension_name: owner,
1441-
actual_tool_name: actual.to_string(),
1442-
client,
1443-
});
1444-
}
1445-
}
1446-
14471538
let tools = self.get_all_tools_cached(session_id).await.map_err(|e| {
14481539
ErrorData::new(
14491540
ErrorCode::INTERNAL_ERROR,
@@ -1453,13 +1544,19 @@ impl ExtensionManager {
14531544
})?;
14541545

14551546
if let Some(tool) = tools.iter().find(|t| *t.name == *tool_name) {
1456-
let owner = get_tool_owner(tool).ok_or_else(|| {
1457-
ErrorData::new(
1458-
ErrorCode::RESOURCE_NOT_FOUND,
1459-
format!("Tool '{}' has no owner", tool_name),
1460-
None,
1461-
)
1462-
})?;
1547+
let owner = get_tool_owner(tool)
1548+
.or_else(|| {
1549+
tool_name
1550+
.split_once("__")
1551+
.map(|(prefix, _)| name_to_key(prefix))
1552+
})
1553+
.ok_or_else(|| {
1554+
ErrorData::new(
1555+
ErrorCode::RESOURCE_NOT_FOUND,
1556+
format!("Tool '{}' has no owner", tool_name),
1557+
None,
1558+
)
1559+
})?;
14631560

14641561
let actual_tool_name = tool_name
14651562
.strip_prefix(&format!("{owner}__"))
@@ -1475,12 +1572,29 @@ impl ExtensionManager {
14751572
})?;
14761573

14771574
return Ok(ResolvedTool {
1575+
tool_name: tool.name.to_string(),
14781576
extension_name: owner,
14791577
actual_tool_name,
14801578
client,
1579+
tool_meta: get_tool_meta_value(tool),
1580+
resource_uri: get_tool_resource_uri(tool),
14811581
});
14821582
}
14831583

1584+
if let Some((prefix, actual)) = tool_name.split_once("__") {
1585+
let owner = name_to_key(prefix);
1586+
if let Some(client) = self.get_server_client(&owner).await {
1587+
return Ok(ResolvedTool {
1588+
tool_name: tool_name.to_string(),
1589+
extension_name: owner,
1590+
actual_tool_name: actual.to_string(),
1591+
client,
1592+
tool_meta: None,
1593+
resource_uri: None,
1594+
});
1595+
}
1596+
}
1597+
14841598
Err(ErrorData::new(
14851599
ErrorCode::RESOURCE_NOT_FOUND,
14861600
format!("Tool '{}' not found", tool_name),
@@ -1516,8 +1630,13 @@ impl ExtensionManager {
15161630

15171631
let arguments = tool_call.arguments.clone();
15181632
let client = resolved.client.clone();
1633+
let hydration_client = client.clone();
15191634
let notifications_receiver = client.subscribe().await;
1520-
let actual_tool_name = resolved.actual_tool_name;
1635+
let actual_tool_name = resolved.actual_tool_name.clone();
1636+
let resolved_tool = resolved;
1637+
let should_hydrate_mcp_app = self.host_supports_mcp_apps();
1638+
let read_cancellation_token = cancellation_token.clone();
1639+
let session_id = ctx.session_id.clone();
15211640
let owned_ctx = ToolCallContext::new(
15221641
ctx.session_id.clone(),
15231642
ctx.working_dir.clone(),
@@ -1531,15 +1650,28 @@ impl ExtensionManager {
15311650
owned_ctx.session_id,
15321651
owned_ctx.working_dir,
15331652
);
1534-
client
1653+
let mut result = client
15351654
.call_tool(&owned_ctx, &actual_tool_name, arguments, cancellation_token)
15361655
.await
15371656
.map_err(|e| match e {
15381657
ServiceError::McpError(error_data) => error_data,
15391658
_ => {
15401659
ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), e.maybe_to_value())
15411660
}
1542-
})
1661+
})?;
1662+
1663+
if should_hydrate_mcp_app {
1664+
Self::hydrate_mcp_app_attachment(
1665+
&hydration_client,
1666+
&session_id,
1667+
&resolved_tool,
1668+
read_cancellation_token,
1669+
&mut result,
1670+
)
1671+
.await;
1672+
}
1673+
1674+
Ok(result)
15431675
};
15441676

15451677
Ok(ToolCallResult {

ui/goose2/src/features/chat/hooks/replayBuffer.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export function getBufferedMessage(
3535
return replayBuffers.get(sessionId)?.find((m) => m.id === messageId);
3636
}
3737

38+
export function getReplayBuffer(sessionId: string): Message[] | undefined {
39+
return replayBuffers.get(sessionId);
40+
}
41+
3842
export function getAndDeleteReplayBuffer(
3943
sessionId: string,
4044
): Message[] | undefined {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { CodeBlock } from "@/shared/ui/ai-elements/code-block";
2+
import { useTranslation } from "react-i18next";
3+
import type { McpAppPayload } from "@/shared/types/messages";
4+
5+
interface McpAppViewProps {
6+
payload: McpAppPayload;
7+
}
8+
9+
export function McpAppView({ payload }: McpAppViewProps) {
10+
const { t } = useTranslation("chat");
11+
12+
// Currently we just render the MCP App payload as JSON.
13+
// Up next, we'll replace this with actual HTML rendering and host bridging.
14+
return (
15+
<div className="my-3" data-testid="mcp-app-view">
16+
<div className="mb-2 text-muted-foreground text-xs uppercase tracking-wide">
17+
{t("message.mcpAppUnderConstruction")}
18+
</div>
19+
<CodeBlock code={JSON.stringify(payload, null, 2)} language="json" />
20+
</div>
21+
);
22+
}

ui/goose2/src/features/chat/ui/MessageBubble.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ import {
3232
} from "@/shared/ui/ai-elements/reasoning";
3333
import { ToolChainCards, type ToolChainItem } from "./ToolChainCards";
3434
import { ClickableImage } from "./ClickableImage";
35+
import { McpAppView } from "./McpAppView";
3536
import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler";
3637
import type {
3738
Message,
3839
MessageAttachment,
3940
MessageContent,
4041
TextContent,
4142
ImageContent,
43+
McpAppContent,
4244
ToolResponseContent,
4345
ThinkingContent,
4446
ReasoningContent as ReasoningContentType,
@@ -224,6 +226,10 @@ function renderContentBlock(
224226
case "toolResponse":
225227
// Handled by groupContentSections toolChain rendering
226228
return null;
229+
case "mcpApp": {
230+
const mcpApp = content as McpAppContent;
231+
return <McpAppView key={`mcp-app-${index}`} payload={mcpApp.payload} />;
232+
}
227233
case "thinking": {
228234
const th = content as ThinkingContent;
229235
return (

0 commit comments

Comments
 (0)