Skip to content
Merged
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
7 changes: 7 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,13 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::get_pricing,
super::routes::agent::start_agent,
super::routes::agent::resume_agent,
super::routes::agent::stop_agent,
super::routes::agent::restart_agent,
super::routes::agent::update_working_dir,
super::routes::agent::get_tools,
super::routes::agent::read_resource,
super::routes::agent::call_tool,
super::routes::agent::list_apps,
super::routes::agent::update_from_session,
super::routes::agent::agent_add_extension,
super::routes::agent::agent_remove_extension,
Expand Down Expand Up @@ -533,8 +535,11 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::ReadResourceResponse,
super::routes::agent::CallToolRequest,
super::routes::agent::CallToolResponse,
super::routes::agent::ListAppsRequest,
super::routes::agent::ListAppsResponse,
super::routes::agent::StartAgentRequest,
super::routes::agent::ResumeAgentRequest,
super::routes::agent::StopAgentRequest,
super::routes::agent::RestartAgentRequest,
super::routes::agent::UpdateWorkingDirRequest,
super::routes::agent::UpdateFromSessionRequest,
Expand All @@ -547,6 +552,8 @@ derive_utoipa!(Icon as IconSchema);
super::tunnel::TunnelInfo,
super::tunnel::TunnelState,
super::routes::telemetry::TelemetryEventRequest,
goose::goose_apps::GooseApp,
goose::goose_apps::WindowProps,
goose::goose_apps::McpAppResource,
goose::goose_apps::CspMetadata,
goose::goose_apps::UiMetadata,
Expand Down
85 changes: 84 additions & 1 deletion crates/goose-server/src/routes/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use axum::{
Json, Router,
};
use goose::agents::ExtensionLoadResult;
use goose::goose_apps::{fetch_mcp_apps, GooseApp, McpAppCache};

use base64::Engine;
use goose::agents::ExtensionConfig;
Expand All @@ -35,7 +36,7 @@ use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::{error, warn};

#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateFromSessionRequest {
Expand Down Expand Up @@ -933,6 +934,87 @@ async fn call_tool(
}))
}

#[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
pub struct ListAppsRequest {
session_id: Option<String>,
}

#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListAppsResponse {
pub apps: Vec<GooseApp>,
}

#[utoipa::path(
get,
path = "/agent/list_apps",
params(
ListAppsRequest
),
responses(
(status = 200, description = "List of apps retrieved successfully", body = ListAppsResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn list_apps(
State(state): State<Arc<AppState>>,
Query(params): Query<ListAppsRequest>,
) -> Result<Json<ListAppsResponse>, ErrorResponse> {
let cache = McpAppCache::new().ok();

let Some(session_id) = params.session_id else {
let apps = cache
.as_ref()
.and_then(|c| c.list_apps().ok())
.unwrap_or_default();
return Ok(Json(ListAppsResponse { apps }));
};

let agent = state
.get_agent_for_route(session_id)
.await
.map_err(|status| ErrorResponse {
message: "Failed to get agent".to_string(),
status,
})?;

let apps = fetch_mcp_apps(&agent.extension_manager)
.await
.map_err(|e| ErrorResponse {
message: format!("Failed to list apps: {}", e.message),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;

if let Some(cache) = cache.as_ref() {
let active_extensions: std::collections::HashSet<String> = apps
.iter()
.filter_map(|app| app.mcp_server.clone())
.collect();

for extension_name in active_extensions {
if let Err(e) = cache.delete_extension_apps(&extension_name) {
warn!(
"Failed to clean cache for extension {}: {}",
Comment on lines +994 to +1002
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

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

This logic deletes all cached apps for extensions that have active apps, then immediately re-caches them. This clears the cache even when nothing changed. Only delete cached apps for extensions that are no longer present, or compare cached vs fresh apps to update selectively.

Suggested change
let active_extensions: std::collections::HashSet<String> = apps
.iter()
.filter_map(|app| app.mcp_server.clone())
.collect();
for extension_name in active_extensions {
if let Err(e) = cache.delete_extension_apps(&extension_name) {
warn!(
"Failed to clean cache for extension {}: {}",
// Determine which extensions are currently active based on freshly fetched apps.
let active_extensions: std::collections::HashSet<String> = apps
.iter()
.filter_map(|app| app.mcp_server.clone())
.collect();
// Determine which extensions are present in the cache.
let cached_apps = cache.list_apps().unwrap_or_default();
let cached_extensions: std::collections::HashSet<String> = cached_apps
.iter()
.filter_map(|app| app.mcp_server.clone())
.collect();
// Only delete cached apps for extensions that are no longer active.
for extension_name in cached_extensions.difference(&active_extensions) {
if let Err(e) = cache.delete_extension_apps(extension_name) {
warn!(
"Failed to clean cache for stale extension {}: {}",

Copilot uses AI. Check for mistakes.
extension_name, e
);
}
}

for app in &apps {
if let Err(e) = cache.store_app(app) {
warn!("Failed to cache app {}: {}", app.resource.name, e);
}
}
}

Ok(Json(ListAppsResponse { apps }))
}

pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
Expand All @@ -942,6 +1024,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/list_apps", get(list_apps))
.route("/agent/update_provider", post(update_agent_provider))
.route("/agent/update_from_session", post(update_from_session))
.route("/agent/add_extension", post(agent_add_extension))
Expand Down
206 changes: 200 additions & 6 deletions crates/goose/src/goose_apps/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,203 @@
//! goose Apps module
//!
//! This module contains types and utilities for working with goose Apps,
//! which are UI resources that can be rendered in an MCP server or native
//! goose apps, or something in between.
pub mod resource;

use crate::agents::ExtensionManager;
use crate::config::paths::Paths;
use rmcp::model::ErrorData;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use utoipa::ToSchema;

pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata};

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct WindowProps {
pub width: u32,
pub height: u32,
pub resizable: bool,
}

/// A Goose App combining MCP resource data with Goose-specific metadata
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GooseApp {
#[serde(flatten)]
pub resource: McpAppResource,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_server: Option<String>,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub window_props: Option<WindowProps>,
}

pub struct McpAppCache {
cache_dir: PathBuf,
}

impl McpAppCache {
pub fn new() -> Result<Self, std::io::Error> {
let config_dir = Paths::config_dir();
let cache_dir = config_dir.join("mcp-apps-cache");
Ok(Self { cache_dir })
}

fn cache_key(extension_name: &str, resource_uri: &str) -> String {
let input = format!("{}::{}", extension_name, resource_uri);
let hash = Sha256::digest(input.as_bytes());
format!("{}_{:x}", extension_name, hash)
}

pub fn list_apps(&self) -> Result<Vec<GooseApp>, std::io::Error> {
let mut apps = Vec::new();

if !self.cache_dir.exists() {
return Ok(apps);
}

for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
let path = entry.path();

if path.extension().and_then(|s| s.to_str()) == Some("json") {
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<GooseApp>(&content) {
Ok(app) => apps.push(app),
Err(e) => warn!("Failed to parse cached app from {:?}: {}", path, e),
},
Err(e) => warn!("Failed to read cached app from {:?}: {}", path, e),
}
}
}

Ok(apps)
}

pub fn store_app(&self, app: &GooseApp) -> Result<(), std::io::Error> {
fs::create_dir_all(&self.cache_dir)?;

if let Some(ref extension_name) = app.mcp_server {
let cache_key = Self::cache_key(extension_name, &app.resource.uri);
let app_path = self.cache_dir.join(format!("{}.json", cache_key));
let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?;
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

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

Using to_string_pretty for cache storage wastes disk space. Use serde_json::to_string instead since cache files don't need human readability.

Suggested change
let json = serde_json::to_string_pretty(app).map_err(std::io::Error::other)?;
let json = serde_json::to_string(app).map_err(std::io::Error::other)?;

Copilot uses AI. Check for mistakes.
fs::write(app_path, json)?;
}

Ok(())
}

pub fn get_app(&self, extension_name: &str, resource_uri: &str) -> Option<GooseApp> {
let cache_key = Self::cache_key(extension_name, resource_uri);
let app_path = self.cache_dir.join(format!("{}.json", cache_key));

if !app_path.exists() {
return None;
}

fs::read_to_string(&app_path)
.ok()
.and_then(|content| serde_json::from_str::<GooseApp>(&content).ok())
}

pub fn delete_extension_apps(&self, extension_name: &str) -> Result<usize, std::io::Error> {
let mut deleted_count = 0;

if !self.cache_dir.exists() {
return Ok(0);
}

for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
let path = entry.path();

if path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(app) = serde_json::from_str::<GooseApp>(&content) {
if app.mcp_server.as_deref() == Some(extension_name)
&& fs::remove_file(&path).is_ok()
{
deleted_count += 1;
}
}
}
}
}

Ok(deleted_count)
}
}

pub async fn fetch_mcp_apps(
extension_manager: &ExtensionManager,
) -> Result<Vec<GooseApp>, ErrorData> {
let mut apps = Vec::new();

let ui_resources = extension_manager.get_ui_resources().await?;

for (extension_name, resource) in ui_resources {
match extension_manager
.read_resource(&resource.uri, &extension_name, CancellationToken::default())
.await
{
Ok(read_result) => {
let mut html = String::new();
for content in read_result.contents {
if let rmcp::model::ResourceContents::TextResourceContents { text, .. } =
content
{
html = text;
break;
}
}

if !html.is_empty() {
let mcp_resource = McpAppResource {
uri: resource.uri.clone(),
name: format_resource_name(resource.name.clone()),
description: resource.description.clone(),
mime_type: "text/html;profile=mcp-app".to_string(),
text: Some(html),
blob: None,
meta: None,
};

let app = GooseApp {
resource: mcp_resource,
mcp_server: Some(extension_name),
window_props: Some(WindowProps {
width: 800,
height: 600,
resizable: true,
}),
};

apps.push(app);
}
}
Err(e) => {
warn!(
"Failed to read resource {} from {}: {}",
resource.uri, extension_name, e
);
}
}
}

Ok(apps)
}

fn format_resource_name(name: String) -> String {
name.replace('_', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
Comment on lines +191 to +203
Copy link

Copilot AI Jan 15, 2026

Choose a reason for hiding this comment

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

This function implements title case conversion but doesn't handle edge cases like acronyms or already-capitalized words well. Consider using a library like heck for more robust case conversion, or document the expected input format.

Copilot uses AI. Check for mistakes.
Loading
Loading