-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Standalone mcp apps #6458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Standalone mcp apps #6458
Changes from 19 commits
2311e8d
d1d881c
4378820
aa8f986
6366ee6
0a50cfa
6c67d07
c131bd4
5d7079e
56c30ce
226c768
6e09576
bad48ff
73915e3
9ac6117
3b8ba68
f0ecb1e
ded3330
476e836
756bcd5
5d8e867
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,99 @@ async fn call_tool( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pub struct ListAppsRequest { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| session_id: Option<String>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[serde(default)] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| use_cache: bool, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #[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(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // If use_cache is true or no session_id provided, return cached apps | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if params.use_cache || params.session_id.is_none() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let apps = cache | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .as_ref() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .and_then(|c| c.list_apps().ok()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .unwrap_or_default(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Ok(Json(ListAppsResponse { apps })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Fetch fresh apps from MCP servers | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let session_id = params.session_id.ok_or_else(|| ErrorResponse { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: "Missing session_id for list_apps request".to_string(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status: StatusCode::BAD_REQUEST, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })?; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let session_id = params.session_id.ok_or_else(|| ErrorResponse { | |
| message: "Missing session_id for list_apps request".to_string(), | |
| status: StatusCode::BAD_REQUEST, | |
| })?; | |
| let session_id = params.session_id.unwrap(); |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The session_id is first used in the conditional at line 973, then extracted again with ok_or_else at line 982. This second extraction is unreachable because if params.session_id.is_none() is true, the function returns early at line 978. Consider simplifying by using unwrap() or restructuring the logic to avoid the redundant error handling.
| // Fetch fresh apps from MCP servers | |
| let session_id = params.session_id.ok_or_else(|| ErrorResponse { | |
| message: "Missing session_id for list_apps request".to_string(), | |
| status: StatusCode::BAD_REQUEST, | |
| })?; | |
| // Fetch fresh apps from MCP servers; at this point session_id must be present | |
| let session_id = params.session_id.unwrap(); |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
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.
| 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 {}: {}", |
| 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) | ||||||
| } | ||||||
|
Comment on lines
+47
to
+51
|
||||||
|
|
||||||
| 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 cache_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)?; | ||||||
|
||||||
| 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
AI
Jan 15, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error handling is unreachable. Line 967 checks if session_id is None after line 958 already returned early when session_id is None, making the error case impossible to reach.