Standalone mcp apps (#6458)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-01-16 08:59:04 -05:00
committed by GitHub
parent b1a53f3456
commit 7c62f50c41
15 changed files with 1086 additions and 29 deletions
+7
View File
@@ -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,
@@ -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,
@@ -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,
+84 -1
View File
@@ -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;
@@ -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 {
@@ -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 {}: {}",
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))
@@ -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))
+200 -6
View File
@@ -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)?;
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(" ")
}