Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::get_tools,
super::routes::agent::read_resource,
super::routes::agent::call_tool,
super::routes::agent::update_model_context,
super::routes::agent::list_apps,
super::routes::agent::export_app,
super::routes::agent::import_app,
Expand Down Expand Up @@ -640,6 +641,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::ReadResourceResponse,
super::routes::agent::CallToolRequest,
super::routes::agent::CallToolResponse,
super::routes::agent::UpdateModelContextRequest,
ContentBlockSchema,
super::routes::agent::ListAppsRequest,
super::routes::agent::ListAppsResponse,
Expand Down
49 changes: 49 additions & 0 deletions crates/goose-server/src/routes/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ pub struct CallToolResponse {
_meta: Option<Value>,
}

#[derive(Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateModelContextRequest {
session_id: String,
/// Key identifying the MCP App (typically `"{extension}__{resourceUri}"`)
key: String,
#[serde(default)]
content: Option<Vec<Value>>,
#[serde(default)]
structured_content: Option<Value>,
}

#[derive(Serialize, utoipa::ToSchema)]
pub struct ResumeAgentResponse {
pub session: Session,
Expand Down Expand Up @@ -1100,6 +1112,42 @@ async fn call_tool(
}))
}

#[utoipa::path(
post,
path = "/agent/update_model_context",
request_body = UpdateModelContextRequest,
responses(
(status = 200, description = "Model context updated"),
(status = 401, description = "Unauthorized"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn update_model_context(
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateModelContextRequest>,
) -> Result<(), StatusCode> {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await?;

agent
.update_mcp_app_context(
payload.key,
goose::agents::McpAppContext {
content: payload.content,
structured_content: payload.structured_content,
Comment on lines +1141 to +1143

Choose a reason for hiding this comment

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

P2 Badge Drop structuredContent instead of retaining it unused

This request stores structuredContent on McpAppContext, but the new collection path explicitly never injects that field into the prompt. I checked the production references after this change: outside tests, structured_content is only written here and on the struct, never read. For MCP apps that follow our own guidance and put full hydration payloads in structuredContent, each update now pins that potentially large JSON blob in mcp_app_contexts until another update or session teardown with no effect on model behavior.

Useful? React with 👍 / 👎.

},
)
.await;

Ok(())
}

#[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
pub struct ListAppsRequest {
session_id: Option<String>,
Expand Down Expand Up @@ -1306,6 +1354,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/agent/tools", get(get_tools))
.route("/agent/read_resource", post(read_resource))
.route("/agent/call_tool", post(call_tool))
.route("/agent/update_model_context", post(update_model_context))
.route("/agent/list_apps", get(list_apps))
.route("/agent/export_app/{name}", get(export_app))
.route("/agent/import_app", post(import_app))
Expand Down
197 changes: 197 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ use tracing::{debug, error, info, instrument, warn};
const DEFAULT_MAX_TURNS: u32 = 1000;
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";

fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
Comment on lines +68 to +72

Choose a reason for hiding this comment

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

P2 Badge Stop escaping quotes in app-context body text

collect_mcp_app_contexts() serializes non-text blocks to JSON and then runs them through xml_escape(). Because this helper also rewrites " to &quot;, the fallback you inject into Message::user().with_text(...) is no longer valid JSON—apps that send image/resource blocks will reach the model as {&quot;type&quot;:...} instead of something it can reliably inspect or copy. Escaping </& is enough for element text here; escaping quotes breaks the metadata this feature is trying to preserve.

Useful? React with 👍 / 👎.


/// Context provided by an MCP App via `ui/update-model-context`.
/// Each update overwrites the previous one for the same key.
#[derive(Clone, Debug)]
pub struct McpAppContext {
pub content: Option<Vec<Value>>,
pub structured_content: Option<Value>,
}

/// Context needed for the reply function
pub struct ReplyContext {
pub conversation: Conversation,
Expand Down Expand Up @@ -152,6 +167,9 @@ pub struct Agent {
pub(super) retry_manager: RetryManager,
pub(super) tool_inspection_manager: ToolInspectionManager,
container: Mutex<Option<Container>>,
/// Context provided by MCP Apps via `ui/update-model-context`, keyed by
/// `"{extension_name}__{resource_uri}"`. Only the latest update per key is kept.
pub(super) mcp_app_contexts: Mutex<HashMap<String, McpAppContext>>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -250,6 +268,7 @@ impl Agent {
provider.clone(),
),
container: Mutex::new(None),
mcp_app_contexts: Mutex::new(HashMap::new()),
}
}

Expand Down Expand Up @@ -481,6 +500,54 @@ impl Agent {
self.container.lock().await.clone()
}

/// Store context from an MCP App's `ui/update-model-context` request.
pub async fn update_mcp_app_context(&self, key: String, context: McpAppContext) {
self.mcp_app_contexts.lock().await.insert(key, context);
}

/// Collect all stored MCP App contexts for injection into the conversation.
pub async fn collect_mcp_app_contexts(&self) -> Option<String> {
let contexts = self.mcp_app_contexts.lock().await;
if contexts.is_empty() {
return None;
}

let mut parts = Vec::new();
for (key, ctx) in contexts.iter() {
let escaped_key = xml_escape(key);
let mut section = format!("<mcp-app-context source=\"{escaped_key}\">\n");
if let Some(content) = &ctx.content {
for block in content {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
section.push_str(&xml_escape(text));
} else {
// Serialize non-text blocks but strip large binary
// payloads (e.g. base64 image data) to avoid bloating
// the synthetic message with thousands of tokens.
let mut stripped = block.clone();
if let Some(obj) = stripped.as_object_mut() {
obj.remove("data");
}
if let Ok(json_str) = serde_json::to_string(&stripped) {
section.push_str(&xml_escape(&json_str));
}
}
section.push('\n');
}
}
if let Some(structured) = &ctx.structured_content {
if let Ok(json_str) = serde_json::to_string_pretty(structured) {
section.push_str(&xml_escape(&json_str));
section.push('\n');
}
}
section.push_str("</mcp-app-context>");
parts.push(section);
}

Some(parts.join("\n"))
}

/// Check if a tool is a frontend tool
pub async fn is_frontend_tool(&self, name: &str) -> bool {
self.frontend_tools.lock().await.contains_key(name)
Expand Down Expand Up @@ -1184,11 +1251,13 @@ impl Agent {
break;
}

let mcp_app_context = self.collect_mcp_app_contexts().await;
let conversation_with_moim = super::moim::inject_moim(
&session_config.id,
conversation.clone(),
&self.extension_manager,
&working_dir,
mcp_app_context,
).await;

let mut stream = Self::stream_response_from_provider(
Expand Down Expand Up @@ -2308,4 +2377,132 @@ mod tests {

Ok(())
}

#[tokio::test]
async fn test_mcp_app_context_empty_returns_none() {
let agent = Agent::new();
assert!(agent.collect_mcp_app_contexts().await.is_none());
}

#[tokio::test]
async fn test_mcp_app_context_stores_and_collects() {
let agent = Agent::new();

agent
.update_mcp_app_context(
"ext__ui://ext/tool".to_string(),
McpAppContext {
content: Some(vec![
serde_json::json!({"type": "text", "text": "User selected 3 items"}),
]),
structured_content: None,
},
)
.await;

let result = agent.collect_mcp_app_contexts().await;
assert!(result.is_some());
let text = result.unwrap();
assert!(text.contains("User selected 3 items"));
assert!(text.contains("ext__ui://ext/tool"));
}

#[tokio::test]
async fn test_mcp_app_context_overwrites_previous() {
let agent = Agent::new();
let key = "ext__ui://ext/tool".to_string();

agent
.update_mcp_app_context(
key.clone(),
McpAppContext {
content: Some(vec![
serde_json::json!({"type": "text", "text": "old context"}),
]),
structured_content: None,
},
)
.await;

agent
.update_mcp_app_context(
key,
McpAppContext {
content: Some(vec![
serde_json::json!({"type": "text", "text": "new context"}),
]),
structured_content: None,
},
)
.await;

let text = agent.collect_mcp_app_contexts().await.unwrap();
assert!(text.contains("new context"));
assert!(!text.contains("old context"));
}

#[tokio::test]
async fn test_mcp_app_context_structured_content() {
let agent = Agent::new();

agent
.update_mcp_app_context(
"ext__ui://ext/tool".to_string(),
McpAppContext {
content: None,
structured_content: Some(serde_json::json!({"items": 3, "total": 150})),
},
)
.await;

let text = agent.collect_mcp_app_contexts().await.unwrap();
assert!(text.contains("&quot;items&quot;: 3"));
assert!(text.contains("&quot;total&quot;: 150"));
}

#[tokio::test]
async fn test_mcp_app_context_escapes_xml_special_chars() {
let agent = Agent::new();

agent
.update_mcp_app_context(
"ext__<script>".to_string(),
McpAppContext {
content: Some(vec![
serde_json::json!({"type": "text", "text": "a < b & c > d"}),
]),
structured_content: None,
},
)
.await;

let text = agent.collect_mcp_app_contexts().await.unwrap();
assert!(text.contains("&lt;script&gt;"));
assert!(text.contains("a &lt; b &amp; c &gt; d"));
assert!(!text.contains("<script>"));
}

#[tokio::test]
async fn test_mcp_app_context_non_text_blocks_serialized_without_data() {
let agent = Agent::new();

agent
.update_mcp_app_context(
"ext__ui://app".to_string(),
McpAppContext {
content: Some(vec![
serde_json::json!({"type": "text", "text": "hello"}),
serde_json::json!({"type": "image", "data": "iVBORw0KGgoAAAANS...", "mimeType": "image/png"}),
]),
structured_content: None,
},
)
.await;

let text = agent.collect_mcp_app_contexts().await.unwrap();
assert!(text.contains("hello"));
assert!(text.contains("&quot;type&quot;:&quot;image&quot;"));
assert!(text.contains("&quot;mimeType&quot;:&quot;image/png&quot;"));
assert!(!text.contains("iVBORw0KGgoAAAANS"));
}
}
4 changes: 3 additions & 1 deletion crates/goose/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ mod tool_execution;
pub mod types;
pub mod validate_extensions;

pub use agent::{Agent, AgentConfig, AgentEvent, ExtensionLoadResult, GoosePlatform};
pub use agent::{
Agent, AgentConfig, AgentEvent, ExtensionLoadResult, GoosePlatform, McpAppContext,
};
pub use container::Container;
pub use execute_commands::COMPACT_TRIGGERS;
pub use extension::{ExtensionConfig, ExtensionError};
Expand Down
Loading
Loading