Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions crates/goose/src/acp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1532,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(())
Expand Down Expand Up @@ -1629,6 +1628,16 @@ 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("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<CallToolResult>) -> Vec<ToolCallContent> {
match tool_result {
Ok(result) => result
Expand Down Expand Up @@ -2144,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) => {
Expand Down
178 changes: 155 additions & 23 deletions crates/goose/src/agents/extension_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +121,20 @@ 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>,
}

/// Manages goose extensions / MCP clients and their interactions
pub struct ExtensionManager {
extensions: Mutex<HashMap<String, Extension>>,
Expand Down Expand Up @@ -214,6 +228,42 @@ 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 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, .. } => {
Expand Down Expand Up @@ -241,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<Value>,
resource_uri: Option<String>,
}

async fn child_process_client(
Expand Down Expand Up @@ -1063,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;
Expand Down Expand Up @@ -1432,17 +1534,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,
Comment on lines 1537 to 1539
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep prefixed tool dispatch independent of tool listing failures

In resolve_tool, the new unconditional get_all_tools_cached(session_id) call can fail before the prefixed fallback branch runs, so a valid call like ext__tool now returns an internal error whenever tool listing fails (for example, due to an unrelated extension timing out). The previous behavior allowed prefixed tool calls to route directly to the owning client without requiring a successful global tool refresh, so this change makes dispatch reliability depend on list-tools health rather than call-tool availability.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this reproduces with the current implementation. resolve_tool now intentionally checks the listed tool set first so prefixed calls still respect available_tools filtering and preserve MCP app metadata. Also, fetch_all_tools() already degrades individual list_tools failures to warnings/empty results, so unrelated extension timeouts don’t usually cause get_all_tools_cached() to fail before the prefixed fallback runs. The remaining coupling is only on a true top-level internal cache/list failure, which is much narrower than the scenario described.

Expand All @@ -1452,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}__"))
Expand All @@ -1474,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),
Expand Down Expand Up @@ -1515,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(),
Expand All @@ -1530,15 +1649,28 @@ 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 {
ServiceError::McpError(error_data) => error_data,
_ => {
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 {
Expand Down
4 changes: 4 additions & 0 deletions ui/goose2/src/features/chat/hooks/replayBuffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions ui/goose2/src/features/chat/ui/McpAppView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="my-3" data-testid="mcp-app-view">
<div className="mb-2 text-muted-foreground text-xs uppercase tracking-wide">
{t("message.mcpAppUnderConstruction")}
</div>
<CodeBlock code={JSON.stringify(payload, null, 2)} language="json" />
</div>
);
}
6 changes: 4 additions & 2 deletions ui/goose2/src/features/chat/ui/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
});
}
Expand Down Expand Up @@ -232,6 +232,8 @@ function renderContentBlock(
case "toolResponse":
// Handled by groupContentSections toolChain rendering
return null;
case "mcpApp":
return <McpAppView key={`mcp-app-${index}`} payload={content.payload} />;
case "thinking":
case "reasoning": {
const text = (content as ThinkingContent | ReasoningContentType).text;
Expand Down
Loading
Loading