Vibe mcp apps (#6569)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2026-01-22 13:03:44 -05:00
committed by GitHub
parent 13bdff4bb5
commit 01e607a12d
28 changed files with 2198 additions and 294 deletions
+4
View File
@@ -366,6 +366,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::read_resource,
super::routes::agent::call_tool,
super::routes::agent::list_apps,
super::routes::agent::export_app,
super::routes::agent::import_app,
super::routes::agent::update_from_session,
super::routes::agent::agent_add_extension,
super::routes::agent::agent_remove_extension,
@@ -544,6 +546,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::CallToolResponse,
super::routes::agent::ListAppsRequest,
super::routes::agent::ListAppsResponse,
super::routes::agent::ImportAppRequest,
super::routes::agent::ImportAppResponse,
super::routes::agent::StartAgentRequest,
super::routes::agent::ResumeAgentRequest,
super::routes::agent::StopAgentRequest,
+121 -3
View File
@@ -32,7 +32,7 @@ use goose::{
use rmcp::model::{CallToolRequestParam, Content};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -1000,9 +1000,9 @@ async fn list_apps(
})?;
if let Some(cache) = cache.as_ref() {
let active_extensions: std::collections::HashSet<String> = apps
let active_extensions: HashSet<String> = apps
.iter()
.filter_map(|app| app.mcp_server.clone())
.flat_map(|app| app.mcp_servers.iter().cloned())
.collect();
for extension_name in active_extensions {
@@ -1024,6 +1024,122 @@ async fn list_apps(
Ok(Json(ListAppsResponse { apps }))
}
#[utoipa::path(
get,
path = "/agent/export_app/{name}",
params(
("name" = String, Path, description = "Name of the app to export")
),
responses(
(status = 200, description = "App HTML exported successfully", body = String),
(status = 404, description = "App not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn export_app(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<impl IntoResponse, ErrorResponse> {
let cache = McpAppCache::new().map_err(|e| ErrorResponse {
message: format!("Failed to access app cache: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let apps = cache.list_apps().map_err(|e| ErrorResponse {
message: format!("Failed to list apps: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let app = apps
.into_iter()
.find(|a| a.resource.name == name)
.ok_or_else(|| ErrorResponse {
message: format!("App '{}' not found", name),
status: StatusCode::NOT_FOUND,
})?;
let html = app.to_html().map_err(|e| ErrorResponse {
message: format!("Failed to generate HTML: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
Ok(html)
}
#[derive(Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportAppRequest {
pub html: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportAppResponse {
pub name: String,
pub message: String,
}
#[utoipa::path(
post,
path = "/agent/import_app",
request_body = ImportAppRequest,
responses(
(status = 201, description = "App imported successfully", body = ImportAppResponse),
(status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn import_app(
Json(body): Json<ImportAppRequest>,
) -> Result<(StatusCode, Json<ImportAppResponse>), ErrorResponse> {
let cache = McpAppCache::new().map_err(|e| ErrorResponse {
message: format!("Failed to access app cache: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let mut app = GooseApp::from_html(&body.html).map_err(|e| ErrorResponse {
message: format!("Invalid Goose App HTML: {}", e),
status: StatusCode::BAD_REQUEST,
})?;
let original_name = app.resource.name.clone();
let mut counter = 1;
let existing_apps = cache.list_apps().unwrap_or_default();
let existing_names: HashSet<String> = existing_apps
.iter()
.map(|a| a.resource.name.clone())
.collect();
while existing_names.contains(&app.resource.name) {
app.resource.name = format!("{}_{}", original_name, counter);
app.resource.uri = format!("ui://apps/{}", app.resource.name);
counter += 1;
}
app.mcp_servers = vec!["apps".to_string()];
cache.store_app(&app).map_err(|e| ErrorResponse {
message: format!("Failed to store app: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
Ok((
StatusCode::CREATED,
Json(ImportAppResponse {
name: app.resource.name.clone(),
message: format!("App '{}' imported successfully", app.resource.name),
}),
))
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
@@ -1034,6 +1150,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/agent/read_resource", post(read_resource))
.route("/agent/call_tool", post(call_tool))
.route("/agent/list_apps", get(list_apps))
.route("/agent/export_app/{name}", get(export_app))
.route("/agent/import_app", post(import_app))
.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))
+20
View File
@@ -1301,6 +1301,26 @@ impl Agent {
ToolStreamItem::Result(output) => {
let output = call_tool_result::validate(output);
// Platform extensions use meta as a way to publish notifications. Ideally we'd
// send the notifications directly, but the current plumbing doesn't support that
// well:
if let Ok(ref call_result) = output {
if let Some(ref meta) = call_result.meta {
if let Some(notification_data) = meta.0.get("platform_notification") {
if let Some(method) = notification_data.get("method").and_then(|v| v.as_str()) {
let params = notification_data.get("params").cloned();
let custom_notification = rmcp::model::CustomNotification::new(
method.to_string(),
params,
);
let server_notification = rmcp::model::ServerNotification::CustomNotification(custom_notification);
yield AgentEvent::McpNotification((request_id.clone(), server_notification));
}
}
}
}
if enable_extension_request_ids.contains(&request_id)
&& output.is_err()
{
+669
View File
@@ -0,0 +1,669 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait};
use crate::config::paths::Paths;
use crate::conversation::message::Message;
use crate::goose_apps::McpAppResource;
use crate::goose_apps::{GooseApp, WindowProps};
use crate::prompt_template::render_template;
use crate::providers::base::Provider;
use async_trait::async_trait;
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListResourcesResult,
ListToolsResult, Meta, ProtocolVersion, RawResource, ReadResourceResult, Resource,
ResourceContents, ResourcesCapability, ServerCapabilities, Tool as McpTool, ToolsCapability,
};
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
pub static EXTENSION_NAME: &str = "apps";
const DEFAULT_WINDOW_PROPS: WindowProps = WindowProps {
width: 800,
height: 600,
resizable: true,
};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct CreateAppParams {
/// What the app should do - a description or PRD that will be used to generate the app
prd: String,
}
/// Parameters for iterate_app tool
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct IterateAppParams {
/// Name of the app to iterate on
name: String,
/// Feedback or requested changes to improve the app
feedback: String,
}
/// Parameters for delete_app tool
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct DeleteAppParams {
/// Name of the app to delete
name: String,
}
/// Parameters for list_apps tool
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListAppsParams {
// No parameters needed - lists all apps
}
/// Response from create_app_content tool
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct CreateAppContentResponse {
/// App name (lowercase, hyphens allowed, no spaces)
name: String,
/// Brief description of what the app does (1-2 sentences, max 100 chars)
description: String,
/// Complete HTML code for the app, from <!DOCTYPE html> to </html>
html: String,
/// Window width in pixels (recommended: 400-1600)
width: Option<u32>,
/// Window height in pixels (recommended: 300-1200)
height: Option<u32>,
/// Whether the window should be resizable
resizable: Option<bool>,
}
/// Response from update_app_content tool
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct UpdateAppContentResponse {
/// Updated description of what the app does (1-2 sentences, max 100 chars)
description: String,
/// Complete updated HTML code for the app, from <!DOCTYPE html> to </html>
html: String,
/// Updated PRD reflecting the current state of the app after this iteration
prd: String,
/// Updated window width in pixels (optional - only if size should change)
width: Option<u32>,
/// Updated window height in pixels (optional - only if size should change)
height: Option<u32>,
/// Updated resizable property (optional - only if it should change)
resizable: Option<bool>,
}
pub struct AppsManagerClient {
info: InitializeResult,
context: PlatformExtensionContext,
apps_dir: PathBuf,
}
impl AppsManagerClient {
pub fn new(context: PlatformExtensionContext) -> Result<Self, String> {
let apps_dir = Paths::in_data_dir(EXTENSION_NAME);
fs::create_dir_all(&apps_dir)
.map_err(|e| format!("Failed to create apps directory: {}", e))?;
let client = Self {
info: Self::create_info(),
context,
apps_dir,
};
client.ensure_default_apps()?;
Ok(client)
}
fn create_info() -> InitializeResult {
InitializeResult {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
resources: Some(ResourcesCapability {
subscribe: Some(false),
list_changed: Some(false),
}),
prompts: None,
completions: None,
experimental: None,
tasks: None,
logging: None,
},
server_info: Implementation {
name: EXTENSION_NAME.to_string(),
title: Some("Apps Manager".to_string()),
version: "1.0.0".to_string(),
icons: None,
website_url: None,
},
instructions: Some(
"Use this extension to create, manage, and iterate on custom HTML/CSS/JavaScript apps."
.to_string(),
),
}
}
fn ensure_default_apps(&self) -> Result<(), String> {
// TODO(Douwe): we have the same check in cache, consider unfiying that
const CLOCK_HTML: &str = include_str!("../goose_apps/clock.html");
// Check if clock app exists
let clock_path = self.apps_dir.join("clock.html");
if !clock_path.exists() {
// Parse and save the default clock app
let clock_app = GooseApp::from_html(CLOCK_HTML)?;
self.save_app(&clock_app)?;
}
Ok(())
}
fn list_stored_apps(&self) -> Result<Vec<String>, String> {
let mut apps = Vec::new();
let entries = fs::read_dir(&self.apps_dir)
.map_err(|e| format!("Failed to read apps directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("html") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
apps.push(stem.to_string());
}
}
}
apps.sort();
Ok(apps)
}
fn load_app(&self, name: &str) -> Result<GooseApp, String> {
let path = self.apps_dir.join(format!("{}.html", name));
let html =
fs::read_to_string(&path).map_err(|e| format!("Failed to read app file: {}", e))?;
GooseApp::from_html(&html)
}
fn save_app(&self, app: &GooseApp) -> Result<(), String> {
let path = self.apps_dir.join(format!("{}.html", app.resource.name));
let html_content = app.to_html()?;
fs::write(&path, html_content).map_err(|e| format!("Failed to write app file: {}", e))?;
Ok(())
}
fn delete_app(&self, name: &str) -> Result<(), String> {
let path = self.apps_dir.join(format!("{}.html", name));
fs::remove_file(&path).map_err(|e| format!("Failed to delete app file: {}", e))?;
Ok(())
}
fn with_platform_notification(
&self,
result: CallToolResult,
event_type: &str,
app_name: &str,
) -> CallToolResult {
let mut params = serde_json::Map::new();
params.insert("app_name".to_string(), json!(app_name));
self.context
.result_with_platform_notification(result, EXTENSION_NAME, event_type, params)
}
async fn get_provider(&self) -> Result<Arc<dyn Provider>, String> {
let extension_manager = self
.context
.extension_manager
.as_ref()
.and_then(|weak| weak.upgrade())
.ok_or("Extension manager not available")?;
let provider_guard = extension_manager.get_provider().lock().await;
let provider = provider_guard
.as_ref()
.ok_or("Provider not available")?
.clone();
Ok(provider)
}
fn schema<T: JsonSchema>() -> JsonObject {
serde_json::to_value(schema_for!(T))
.map(|v| {
v.as_object()
.expect("schema_for!(T) must serialize to a JSON object")
.clone()
})
.expect("Schema serialization must succeed")
}
fn create_app_content_tool() -> rmcp::model::Tool {
rmcp::model::Tool::new(
"create_app_content".to_string(),
"Generate content for a new Goose app. Returns the HTML code, app name, description, and window properties.".to_string(),
Self::schema::<CreateAppContentResponse>(),
)
}
fn update_app_content_tool() -> rmcp::model::Tool {
rmcp::model::Tool::new(
"update_app_content".to_string(),
"Generate updated content for an existing Goose app. Returns the improved HTML code, updated description, and optionally updated window properties.".to_string(),
Self::schema::<UpdateAppContentResponse>(),
)
}
async fn generate_new_app_content(
&self,
session_id: &str,
prd: &str,
) -> Result<CreateAppContentResponse, String> {
let provider = self.get_provider().await?;
let existing_apps = self.list_stored_apps().unwrap_or_default();
let existing_names = existing_apps.join(", ");
let context: HashMap<&str, &str> = HashMap::new();
let system_prompt = render_template("apps_create.md", &context)
.map_err(|e| format!("Failed to render template: {}", e))?;
let user_prompt = format!(
"REQUESTED APP:\n{}\n\nEXISTING APPS: {}\n\nGenerate a unique name (lowercase with hyphens, not in existing apps), a brief description, complete HTML, and appropriate window size for this app.",
prd,
if existing_names.is_empty() { "none" } else { &existing_names }
);
let messages = vec![Message::user().with_text(&user_prompt)];
let tools = vec![Self::create_app_content_tool()];
let (response, _usage) = provider
.complete(session_id, &system_prompt, &messages, &tools)
.await
.map_err(|e| format!("LLM call failed: {}", e))?;
extract_tool_response(&response, "create_app_content")
}
async fn generate_updated_app_content(
&self,
session_id: &str,
existing_html: &str,
existing_prd: &str,
feedback: &str,
) -> Result<UpdateAppContentResponse, String> {
let provider = self.get_provider().await?;
let context: HashMap<&str, &str> = HashMap::new();
let system_prompt = render_template("apps_iterate.md", &context)
.map_err(|e| format!("Failed to render template: {}", e))?;
let user_prompt = format!(
"ORIGINAL PRD:\n{}\n\nCURRENT APP:\n```html\n{}\n```\n\nFEEDBACK: {}\n\nImplement the requested changes and return:\n1. Updated description\n2. Updated HTML implementing the feedback\n3. Updated PRD reflecting the current state of the app\n4. Optionally updated window size if appropriate",
existing_prd,
existing_html,
feedback
);
let messages = vec![Message::user().with_text(&user_prompt)];
let tools = vec![Self::update_app_content_tool()];
let (response, _usage) = provider
.complete(session_id, &system_prompt, &messages, &tools)
.await
.map_err(|e| format!("LLM call failed: {}", e))?;
extract_tool_response(&response, "update_app_content")
}
async fn handle_list_apps(
&self,
_arguments: Option<JsonObject>,
) -> Result<CallToolResult, String> {
let app_names = self.list_stored_apps()?;
if app_names.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No apps found. Create your first app with the create_app tool!".to_string(),
)]));
}
let mut apps_info = vec![format!("Found {} app(s):\n", app_names.len())];
for name in app_names {
match self.load_app(&name) {
Ok(app) => {
let description = app
.resource
.description
.as_deref()
.unwrap_or("No description");
let size = if let Some(ref props) = app.window_props {
format!(" ({}x{})", props.width, props.height)
} else {
String::new()
};
apps_info.push(format!("- {}{}: {}", name, size, description));
}
Err(e) => {
apps_info.push(format!("- {}: (error loading: {})", name, e));
}
}
}
Ok(CallToolResult::success(vec![Content::text(
apps_info.join("\n"),
)]))
}
async fn handle_create_app(
&self,
session_id: &str,
arguments: Option<JsonObject>,
) -> Result<CallToolResult, String> {
let args = arguments.ok_or("Missing arguments")?;
let prd = extract_string(&args, "prd")?;
let content = self.generate_new_app_content(session_id, &prd).await?;
if self.load_app(&content.name).is_ok() {
return Err(format!(
"App '{}' already exists (generated name conflicts with existing app).",
content.name
));
}
let app = GooseApp {
resource: McpAppResource {
uri: format!("ui://apps/{}", content.name),
name: content.name.clone(),
description: Some(content.description),
mime_type: "text/html;profile=mcp-app".to_string(),
text: Some(content.html),
blob: None,
meta: None,
},
mcp_servers: vec![EXTENSION_NAME.to_string()],
window_props: Some(WindowProps {
width: content.width.unwrap_or(DEFAULT_WINDOW_PROPS.width),
height: content.height.unwrap_or(DEFAULT_WINDOW_PROPS.height),
resizable: content.resizable.unwrap_or(DEFAULT_WINDOW_PROPS.resizable),
}),
prd: Some(prd),
};
self.save_app(&app)?;
let result = CallToolResult::success(vec![Content::text(format!(
"Created app '{}'! It should have automatically opened in a new window. You can always find it again in the [Apps] tab.",
content.name
))]);
Ok(self.with_platform_notification(result, "app_created", &content.name))
}
async fn handle_iterate_app(
&self,
session_id: &str,
arguments: Option<JsonObject>,
) -> Result<CallToolResult, String> {
let args = arguments.ok_or("Missing arguments")?;
let name = extract_string(&args, "name")?;
let feedback = extract_string(&args, "feedback")?;
let mut app = self.load_app(&name)?;
let existing_html = app
.resource
.text
.as_deref()
.ok_or("App has no HTML content")?;
let existing_prd = app.prd.as_deref().unwrap_or("");
let content = self
.generate_updated_app_content(session_id, existing_html, existing_prd, &feedback)
.await?;
app.resource.text = Some(content.html);
app.resource.description = Some(content.description);
app.prd = Some(content.prd);
if content.width.is_some() || content.height.is_some() || content.resizable.is_some() {
let current_props = app.window_props.as_ref();
let default_width = current_props
.map(|p| p.width)
.unwrap_or(DEFAULT_WINDOW_PROPS.width);
let default_height = current_props
.map(|p| p.height)
.unwrap_or(DEFAULT_WINDOW_PROPS.height);
let default_resizable = current_props
.map(|p| p.resizable)
.unwrap_or(DEFAULT_WINDOW_PROPS.resizable);
app.window_props = Some(WindowProps {
width: content.width.unwrap_or(default_width),
height: content.height.unwrap_or(default_height),
resizable: content.resizable.unwrap_or(default_resizable),
});
}
self.save_app(&app)?;
let result = CallToolResult::success(vec![Content::text(format!(
"Updated app '{}' based on your feedback",
name
))]);
Ok(self.with_platform_notification(result, "app_updated", &name))
}
async fn handle_delete_app(
&self,
arguments: Option<JsonObject>,
) -> Result<CallToolResult, String> {
let args = arguments.ok_or("Missing arguments")?;
let name = extract_string(&args, "name")?;
self.delete_app(&name)?;
let result =
CallToolResult::success(vec![Content::text(format!("Deleted app '{}'", name))]);
Ok(self.with_platform_notification(result, "app_deleted", &name))
}
}
#[async_trait]
impl McpClientTrait for AppsManagerClient {
async fn list_tools(
&self,
_session_id: &str,
_next_cursor: Option<String>,
_cancel_token: CancellationToken,
) -> Result<ListToolsResult, Error> {
let tools = vec![
McpTool::new(
"list_apps".to_string(),
"List all available Goose apps with their names and descriptions. Use this to see what apps exist before creating or modifying apps.".to_string(),
schema::<ListAppsParams>(),
),
McpTool::new(
"create_app".to_string(),
"Create a new Goose app based on a description or PRD. The extension will use an LLM to generate the HTML/CSS/JavaScript. Apps are sandboxed and run in standalone windows.".to_string(),
schema::<CreateAppParams>(),
),
McpTool::new(
"iterate_app".to_string(),
"Improve an existing app based on feedback. The extension will use an LLM to update the HTML while preserving the app's intent.".to_string(),
schema::<IterateAppParams>(),
),
McpTool::new(
"delete_app".to_string(),
"Delete an app permanently".to_string(),
schema::<DeleteAppParams>(),
),
];
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
}
async fn call_tool(
&self,
session_id: &str,
name: &str,
arguments: Option<JsonObject>,
_cancel_token: CancellationToken,
) -> Result<CallToolResult, Error> {
let result = match name {
"list_apps" => self.handle_list_apps(arguments).await,
"create_app" => self.handle_create_app(session_id, arguments).await,
"iterate_app" => self.handle_iterate_app(session_id, arguments).await,
"delete_app" => self.handle_delete_app(arguments).await,
_ => Err(format!("Unknown tool: {}", name)),
};
match result {
Ok(result) => Ok(result),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {}",
error
))])),
}
}
async fn list_resources(
&self,
_session_id: &str,
_next_cursor: Option<String>,
_cancel_token: CancellationToken,
) -> Result<ListResourcesResult, Error> {
let app_names = self
.list_stored_apps()
.map_err(|_| Error::TransportClosed)?;
let mut resources = Vec::new();
for name in app_names {
if let Ok(app) = self.load_app(&name) {
let meta = if let Some(ref window_props) = app.window_props {
let mut meta_obj = Meta::new();
meta_obj.insert(
"window".to_string(),
json!({
"width": window_props.width,
"height": window_props.height,
"resizable": window_props.resizable,
}),
);
Some(meta_obj)
} else {
None
};
let raw_resource = RawResource {
uri: app.resource.uri.clone(),
name: app.resource.name.clone(),
title: None,
description: app.resource.description.clone(),
mime_type: Some(app.resource.mime_type.clone()),
size: None,
icons: None,
meta,
};
resources.push(Resource {
raw: raw_resource,
annotations: None,
});
}
}
Ok(ListResourcesResult {
resources,
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
_session_id: &str,
uri: &str,
_cancel_token: CancellationToken,
) -> Result<ReadResourceResult, Error> {
let app_name = uri
.strip_prefix("ui://apps/")
.ok_or(Error::TransportClosed)?;
let app = self
.load_app(app_name)
.map_err(|_| Error::TransportClosed)?;
let html = app
.resource
.text
.unwrap_or_else(|| String::from("No content"));
Ok(ReadResourceResult {
contents: vec![ResourceContents::text(html, uri)],
})
}
fn get_info(&self) -> Option<&InitializeResult> {
Some(&self.info)
}
}
fn schema<T: JsonSchema>() -> JsonObject {
serde_json::to_value(schema_for!(T))
.map(|v| v.as_object().unwrap().clone())
.expect("valid schema")
}
fn extract_string(args: &JsonObject, key: &str) -> Result<String, String> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("Missing or invalid '{}'", key))
}
fn extract_tool_response<T: serde::de::DeserializeOwned>(
response: &Message,
tool_name: &str,
) -> Result<T, String> {
for content in &response.content {
if let crate::conversation::message::MessageContent::ToolRequest(tool_req) = content {
if let Ok(tool_call) = &tool_req.tool_call {
if tool_call.name == tool_name {
let params = tool_call
.arguments
.as_ref()
.ok_or("Missing tool call parameters")?;
return serde_json::from_value(serde_json::Value::Object(params.clone()))
.map_err(|e| format!("Failed to parse tool response: {}", e));
}
}
}
}
Err(format!("LLM did not call the required tool: {}", tool_name))
}
+44
View File
@@ -1,3 +1,4 @@
use crate::agents::apps_extension;
use crate::agents::chatrecall_extension;
use crate::agents::code_execution_extension;
use crate::agents::extension_manager_extension;
@@ -54,6 +55,17 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
map.insert(
apps_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: apps_extension::EXTENSION_NAME,
description:
"Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.",
default_enabled: true,
client_factory: |ctx| Box::new(apps_extension::AppsManagerClient::new(ctx).unwrap()),
},
);
map.insert(
chatrecall_extension::EXTENSION_NAME,
PlatformExtensionDef {
@@ -111,6 +123,38 @@ pub struct PlatformExtensionContext {
pub session_manager: std::sync::Arc<crate::session::SessionManager>,
}
impl PlatformExtensionContext {
pub fn result_with_platform_notification(
&self,
mut result: rmcp::model::CallToolResult,
extension_name: impl Into<String>,
event_type: impl Into<String>,
mut additional_params: serde_json::Map<String, serde_json::Value>,
) -> rmcp::model::CallToolResult {
additional_params.insert("extension".to_string(), extension_name.into().into());
additional_params.insert("event_type".to_string(), event_type.into().into());
let meta_value = serde_json::json!({
"platform_notification": {
"method": "platform_event",
"params": additional_params
}
});
if let Some(ref mut meta) = result.meta {
if let Some(obj) = meta_value.as_object() {
for (k, v) in obj {
meta.0.insert(k.clone(), v.clone());
}
}
} else {
result.meta = Some(rmcp::model::Meta(meta_value.as_object().unwrap().clone()));
}
result
}
}
#[derive(Debug, Clone)]
pub struct PlatformExtensionDef {
pub name: &'static str,
@@ -466,6 +466,10 @@ impl ExtensionManager {
&self.context
}
pub fn get_provider(&self) -> &SharedProvider {
&self.provider
}
pub async fn supports_resources(&self) -> bool {
self.extensions
.lock()
+1
View File
@@ -1,4 +1,5 @@
mod agent;
pub(crate) mod apps_extension;
pub(crate) mod chatrecall_extension;
pub(crate) mod code_execution_extension;
pub mod execute_commands;
+29 -6
View File
@@ -171,6 +171,8 @@ pub enum SystemNotificationType {
pub struct SystemNotificationContent {
pub notification_type: SystemNotificationType,
pub msg: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
@@ -369,6 +371,19 @@ impl MessageContent {
MessageContent::SystemNotification(SystemNotificationContent {
notification_type,
msg: msg.into(),
data: None,
})
}
pub fn system_notification_with_data<S: Into<String>>(
notification_type: SystemNotificationType,
msg: S,
data: serde_json::Value,
) -> Self {
MessageContent::SystemNotification(SystemNotificationContent {
notification_type,
msg: msg.into(),
data: Some(data),
})
}
@@ -816,39 +831,47 @@ impl Message {
.with_metadata(MessageMetadata::user_only())
}
/// Set the visibility metadata for the message
pub fn with_system_notification_with_data<S: Into<String>>(
self,
notification_type: SystemNotificationType,
msg: S,
data: serde_json::Value,
) -> Self {
self.with_content(MessageContent::system_notification_with_data(
notification_type,
msg,
data,
))
.with_metadata(MessageMetadata::user_only())
}
pub fn with_visibility(mut self, user_visible: bool, agent_visible: bool) -> Self {
self.metadata.user_visible = user_visible;
self.metadata.agent_visible = agent_visible;
self
}
/// Set the entire metadata for the message
pub fn with_metadata(mut self, metadata: MessageMetadata) -> Self {
self.metadata = metadata;
self
}
/// Mark the message as only visible to the user (not the agent)
pub fn user_only(mut self) -> Self {
self.metadata.user_visible = true;
self.metadata.agent_visible = false;
self
}
/// Mark the message as only visible to the agent (not the user)
pub fn agent_only(mut self) -> Self {
self.metadata.user_visible = false;
self.metadata.agent_visible = true;
self
}
/// Check if the message is visible to the user
pub fn is_user_visible(&self) -> bool {
self.metadata.user_visible
}
/// Check if the message is visible to the agent
pub fn is_agent_visible(&self) -> bool {
self.metadata.agent_visible
}
+309
View File
@@ -0,0 +1,309 @@
use crate::agents::ExtensionManager;
use rmcp::model::ErrorData;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
use tracing::warn;
use utoipa::ToSchema;
use super::resource::McpAppResource;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct WindowProps {
pub width: u32,
pub height: u32,
pub resizable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GooseApp {
#[serde(flatten)]
pub resource: McpAppResource,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<String>,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub window_props: Option<WindowProps>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prd: Option<String>,
}
impl GooseApp {
const METADATA_SCRIPT_TYPE: &'static str = "application/ld+json";
const PRD_SCRIPT_TYPE: &'static str = "application/x-goose-prd";
const GOOSE_APP_TYPE: &'static str = "GooseApp";
const GOOSE_SCHEMA_CONTEXT: &'static str = "urn:goose.ai:schema";
pub fn from_html(html: &str) -> Result<Self, String> {
use regex::Regex;
let metadata_re = Regex::new(&format!(
r#"(?s)<script type="{}"[^>]*>\s*(.*?)\s*</script>"#,
regex::escape(Self::METADATA_SCRIPT_TYPE)
))
.map_err(|e| format!("Regex error: {}", e))?;
let prd_re = Regex::new(&format!(
r#"(?s)<script type="{}"[^>]*>\s*(.*?)\s*</script>"#,
regex::escape(Self::PRD_SCRIPT_TYPE)
))
.map_err(|e| format!("Regex error: {}", e))?;
let json_str = metadata_re
.captures(html)
.and_then(|cap| cap.get(1))
.ok_or_else(|| "No GooseApp JSON-LD metadata found in HTML".to_string())?
.as_str();
let metadata: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| format!("Failed to parse metadata JSON: {}", e))?;
let name = metadata
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing 'name' in metadata")?
.to_string();
let description = metadata
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
let width = metadata
.get("width")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let height = metadata
.get("height")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let resizable = metadata.get("resizable").and_then(|v| v.as_bool());
let window_props = if width.is_some() || height.is_some() || resizable.is_some() {
Some(WindowProps {
width: width.unwrap_or(800),
height: height.unwrap_or(600),
resizable: resizable.unwrap_or(true),
})
} else {
None
};
let mcp_servers = metadata
.get("mcpServers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let prd = prd_re
.captures(html)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().trim().to_string());
let clean_html = metadata_re.replace(html, "");
let clean_html = prd_re.replace(&clean_html, "").to_string();
Ok(GooseApp {
resource: McpAppResource {
uri: format!("ui://apps/{}", name),
name,
description,
mime_type: "text/html;profile=mcp-app".to_string(),
text: Some(clean_html),
blob: None,
meta: None,
},
mcp_servers,
window_props,
prd,
})
}
pub fn to_html(&self) -> Result<String, String> {
let html = self
.resource
.text
.as_ref()
.ok_or("App has no HTML content")?;
let mut metadata = serde_json::json!({
"@context": Self::GOOSE_SCHEMA_CONTEXT,
"@type": Self::GOOSE_APP_TYPE,
"name": self.resource.name,
});
if let Some(ref desc) = self.resource.description {
metadata["description"] = serde_json::json!(desc);
}
if let Some(ref props) = self.window_props {
metadata["width"] = serde_json::json!(props.width);
metadata["height"] = serde_json::json!(props.height);
metadata["resizable"] = serde_json::json!(props.resizable);
}
if !self.mcp_servers.is_empty() {
metadata["mcpServers"] = serde_json::json!(self.mcp_servers);
}
let metadata_json = serde_json::to_string_pretty(&metadata)
.map_err(|e| format!("Failed to serialize metadata: {}", e))?;
let metadata_script = format!(
" <script type=\"{}\">\n{}\n </script>",
Self::METADATA_SCRIPT_TYPE,
metadata_json
);
let prd_script = if let Some(ref prd) = self.prd {
if !prd.is_empty() {
format!(
" <script type=\"{}\">\n{}\n </script>",
Self::PRD_SCRIPT_TYPE,
prd
)
} else {
String::new()
}
} else {
String::new()
};
let scripts = if prd_script.is_empty() {
format!("{}\n", metadata_script)
} else {
format!("{}\n{}\n", metadata_script, prd_script)
};
let result = if let Some(head_pos) = html.find("</head>") {
let mut result = html.clone();
result.insert_str(head_pos, &scripts);
result
} else if let Some(html_pos) = html.find("<html") {
let after_html = html
.get(html_pos..)
.and_then(|s| s.find('>'))
.map(|p| html_pos + p + 1);
if let Some(pos) = after_html {
let mut result = html.clone();
result.insert_str(pos, &format!("\n<head>\n{}</head>", scripts));
result
} else {
format!("<head>\n{}</head>\n{}", scripts, html)
}
} else {
format!(
"<html>\n<head>\n{}</head>\n<body>\n{}\n</body>\n</html>",
scripts, html
)
};
Ok(result)
}
}
pub async fn fetch_mcp_apps(
extension_manager: &ExtensionManager,
session_id: &str,
) -> Result<Vec<GooseApp>, ErrorData> {
let mut apps = Vec::new();
let ui_resources = extension_manager.get_ui_resources(session_id).await?;
for (extension_name, resource) in ui_resources {
match extension_manager
.read_resource(
session_id,
&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: resource.name.clone(),
description: resource.description.clone(),
mime_type: "text/html;profile=mcp-app".to_string(),
text: Some(html),
blob: None,
meta: None,
};
let window_props = if let Some(ref meta) = resource.meta {
if let Some(window_obj) = meta.get("window").and_then(|v| v.as_object()) {
if let (Some(width), Some(height), Some(resizable)) = (
window_obj
.get("width")
.and_then(|v| v.as_u64())
.map(|v| v as u32),
window_obj
.get("height")
.and_then(|v| v.as_u64())
.map(|v| v as u32),
window_obj.get("resizable").and_then(|v| v.as_bool()),
) {
Some(WindowProps {
width,
height,
resizable,
})
} else {
Some(WindowProps {
width: 800,
height: 600,
resizable: true,
})
}
} else {
Some(WindowProps {
width: 800,
height: 600,
resizable: true,
})
}
} else {
Some(WindowProps {
width: 800,
height: 600,
resizable: true,
})
};
let app = GooseApp {
resource: mcp_resource,
mcp_servers: vec![extension_name],
window_props,
prd: None,
};
apps.push(app);
}
}
Err(e) => {
warn!(
"Failed to read resource {} from {}: {}",
resource.uri, extension_name, e
);
}
}
}
Ok(apps)
}
+118
View File
@@ -0,0 +1,118 @@
use crate::config::paths::Paths;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use tracing::warn;
use super::app::GooseApp;
static CLOCK_HTML: &str = include_str!("../goose_apps/clock.html");
const APPS_EXTENSION_NAME: &str = "apps";
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");
let cache = Self { cache_dir };
cache.ensure_default_apps();
Ok(cache)
}
fn ensure_default_apps(&self) {
if self.get_app(APPS_EXTENSION_NAME, "apps://clock").is_none() {
if let Ok(mut clock_app) = GooseApp::from_html(CLOCK_HTML) {
clock_app.mcp_servers = vec![APPS_EXTENSION_NAME.to_string()];
let _ = self.store_app(&clock_app);
}
}
}
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)?;
// Store the app once for each MCP server it's associated with
for extension_name in &app.mcp_servers {
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_servers.contains(&extension_name.to_string())
&& fs::remove_file(&path).is_ok()
{
deleted_count += 1;
}
}
}
}
}
Ok(deleted_count)
}
}
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Clock</title>
<script type="application/ld+json">
{
"@context": "https://goose.ai/schema",
"@type": "GooseApp",
"name": "clock",
"description": "Swiss Railway Clock",
"width": 300,
"height": 300,
"resizable": false
}
</script>
<script type="application/x-goose-prd">
# Swiss Railway Clock Widget
## Overview
An analog clock widget inspired by the iconic Swiss railway clock (Hans Hilfiker design).
## Core Functionality
### Time Display
- Analog clock face with hour, minute, and second hands
- Second hand sweeps smoothly in small increments (10 updates/second)
- Distinctive "pause and jump" behavior: second hand pauses at 12 o'clock for ~1.5 seconds before jumping to the next minute
- Hour and minute hands update continuously based on current time
- Clean white face with simple black hour markers
### Swiss Clock Behavior
- Second hand: smooth sweep with characteristic pause at top
- During pause: hour and minute hands continue to move
- After pause: second hand jumps to correct position and resumes sweep
- Mimics the synchronization behavior of Swiss railway station clocks
### Visual Design
- Circular white clock face
- Black outline circle
- Simple black tick marks at hour positions
- Red lollipop-style second hand (round tip)
- Black tapered hour and minute hands
- Centered on widget canvas
## Default Dimensions
- Width: 300px
- Height: 300px
- Not resizable (maintains aspect ratio)
## Technical Requirements
- Uses HTML5 canvas for rendering
- Updates 10 times per second for smooth animation
- Properly implements Swiss railway clock timing behavior
- Cleans up animation loop when widget closes
</script>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.clock-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
#clockCanvas {
display: block;
max-width: 100%;
max-height: 100%;
}
</style>
</head>
<body>
<div class="clock-container">
<canvas id="clockCanvas" width="300" height="300"></canvas>
</div>
<script>
class ClockWidget {
constructor() {
this.animationFrame = null;
this.lastSecond = -1;
this.pauseUntil = 0;
}
onMount() {
this.canvas = document.getElementById('clockCanvas');
this.ctx = this.canvas.getContext('2d');
this.animate();
}
onClose() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
}
animate() {
this.drawClock();
this.animationFrame = requestAnimationFrame(() => this.animate());
}
drawClock() {
const now = Date.now();
const date = new Date(now);
const canvas = this.canvas;
const ctx = this.ctx;
const radius = canvas.width / 2;
const centerX = radius;
const centerY = radius;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(centerX, centerY, radius - 5, 0, 2 * Math.PI);
ctx.fillStyle = 'white';
ctx.fill();
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.stroke();
for (let i = 0; i < 60; i++) {
const angle = (i * 6) * Math.PI / 180;
const isHourMark = i % 5 === 0;
if (isHourMark) {
const x1 = centerX + Math.sin(angle) * (radius - 15);
const y1 = centerY - Math.cos(angle) * (radius - 15);
const x2 = centerX + Math.sin(angle) * (radius - 40);
const y2 = centerY - Math.cos(angle) * (radius - 40);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = 'black';
ctx.lineWidth = 8;
ctx.lineCap = 'butt';
ctx.stroke();
} else {
const x1 = centerX + Math.sin(angle) * (radius - 15);
const y1 = centerY - Math.cos(angle) * (radius - 15);
const x2 = centerX + Math.sin(angle) * (radius - 25);
const y2 = centerY - Math.cos(angle) * (radius - 25);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.lineCap = 'butt';
ctx.stroke();
}
}
ctx.font = '16px sans-serif';
ctx.fillStyle = 'black';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('GOOSE CLOCK', centerX, centerY - 65);
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
if (seconds === 0 && this.lastSecond === 59) {
this.pauseUntil = now + 1500;
}
this.lastSecond = seconds;
const hours = date.getHours() % 12;
const minutes = date.getMinutes();
const hourAngle = ((hours + minutes / 60) * 30) * Math.PI / 180;
this.drawHand(ctx, centerX, centerY, hourAngle, radius * 0.65, 11, 'black');
const minuteAngle = ((minutes + seconds / 60) * 6) * Math.PI / 180;
this.drawHand(ctx, centerX, centerY, minuteAngle, radius * 0.90, 8, 'black');
let secondAngle;
if (now < this.pauseUntil) {
secondAngle = 0;
} else {
secondAngle = ((seconds + milliseconds / 1000) * 6) * Math.PI / 180;
}
this.drawSecondHand(ctx, centerX, centerY, secondAngle, radius * 0.65);
ctx.beginPath();
ctx.arc(centerX, centerY, 6, 0, 2 * Math.PI);
ctx.fillStyle = '#d32f2f';
ctx.fill();
}
drawHand(ctx, x, y, angle, length, width, color) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.rect(-width/2, -length, width, length + 20);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
drawSecondHand(ctx, x, y, angle, length) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, length * 0.2);
ctx.lineTo(0, -length);
ctx.strokeStyle = '#d32f2f';
ctx.lineWidth = 2.5;
ctx.lineCap = 'butt';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -length, 10, 0, 2 * Math.PI);
ctx.fillStyle = '#d32f2f';
ctx.fill();
ctx.restore();
}
}
const widget = new ClockWidget();
widget.onMount();
window.addEventListener('beforeunload', () => {
widget.onClose();
});
</script>
</body>
</html>
+4 -206
View File
@@ -1,209 +1,7 @@
pub mod app;
pub mod cache;
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 app::{fetch_mcp_apps, GooseApp, WindowProps};
pub use cache::McpAppCache;
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,
session_id: &str,
) -> Result<Vec<GooseApp>, ErrorData> {
let mut apps = Vec::new();
let ui_resources = extension_manager.get_ui_resources(session_id).await?;
for (extension_name, resource) in ui_resources {
match extension_manager
.read_resource(
session_id,
&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(" ")
}
+8
View File
@@ -23,6 +23,14 @@ static TEMPLATE_REGISTRY: &[(&str, &str)] = &[
"recipe.md",
"Prompt for generating recipe files from conversations",
),
(
"apps_create.md",
"Prompt for generating new Goose apps based on the user instructions",
),
(
"apps_iterate.md",
"Prompt for updating existing Goose apps based on feedback",
),
(
"permission_judge.md",
"Prompt for analyzing tool operations for read-only detection",
+19
View File
@@ -0,0 +1,19 @@
You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications.
REQUIREMENTS:
- Create a complete, self-contained HTML file with embedded CSS and JavaScript
- Use modern, clean design with good UX
- Make it responsive and work well in different window sizes
- Use semantic HTML5
- Add appropriate error handling
- Make the app interactive and functional
- Use vanilla JavaScript; do not load external JavaScript libraries (no JS dependencies from CDNs or packages)
- If you need external resources (fonts, icons, or CSS only), use CDN links from well-known, trusted providers
- The app will be sandboxed with strict CSP, so all JavaScript must be inline; only non-script assets (fonts, icons, CSS) may be loaded from trusted CDNs
WINDOW SIZING:
- Choose appropriate width and height based on the app's content and layout
- Typical sizes: small utilities (400x300), standard apps (800x600), large apps (1200x800)
- Set resizable to false for fixed-size apps, true for flexible layouts
You must call the create_app_content tool to return the app name, description, HTML, and window properties.
+25
View File
@@ -0,0 +1,25 @@
You are an expert HTML/CSS/JavaScript developer. Generate standalone, single-file HTML applications.
REQUIREMENTS:
- Create a complete, self-contained HTML file with embedded CSS and JavaScript
- Use modern, clean design with good UX
- Make it responsive and work well in different window sizes
- Use semantic HTML5
- Add appropriate error handling
- Make the app interactive and functional
- Use vanilla JavaScript; do not load external JavaScript libraries (no JS dependencies from CDNs or packages)
- If you need external resources (fonts, icons, or CSS only), use CDN links from well-known, trusted providers
- The app will be sandboxed with strict CSP, so all JavaScript must be inline; only non-script assets (fonts, icons, CSS) may be loaded from trusted CDNs
WINDOW SIZING:
- Optionally update width/height if the changes warrant a different window size
- Only include size properties if they should change
- Set resizable to false for fixed-size apps, true for flexible layouts
PRD UPDATE:
- Update the PRD to reflect the current state of the app after implementing the feedback
- Keep the core requirements but add/update sections based on what was actually changed
- Document new features, changed behavior, or updated requirements
- Keep the PRD concise and focused on what the app should do, not implementation details
You must call the update_app_content tool to return the updated description, HTML, updated PRD, and optionally updated window properties.