850 lines
29 KiB
Rust
850 lines
29 KiB
Rust
use crate::agents::extension::PlatformExtensionContext;
|
|
use crate::agents::mcp_client::{Error, McpClientTrait};
|
|
use crate::agents::reply_parts::coerce_tool_arguments;
|
|
use crate::agents::tool_execution::ToolCallContext;
|
|
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, RawResource, ReadResourceResult, Resource, ResourceContents,
|
|
ServerCapabilities, Tool as McpTool,
|
|
};
|
|
use schemars::{schema_for, JsonSchema};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Component, 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::new(
|
|
ServerCapabilities::builder()
|
|
.enable_tools()
|
|
.enable_resources()
|
|
.build(),
|
|
)
|
|
.with_server_info(Implementation::new(EXTENSION_NAME, "1.0.0").with_title("Apps Manager"))
|
|
.with_instructions(
|
|
"Use this extension to create, manage, and iterate on custom HTML/CSS/JavaScript apps.",
|
|
)
|
|
}
|
|
|
|
fn ensure_default_apps(&self) -> Result<(), String> {
|
|
// TODO(Douwe): we have the same check in cache, consider unifying 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.app_path(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.app_path(&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.app_path(name)?;
|
|
|
|
fs::remove_file(&path).map_err(|e| format!("Failed to delete app file: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn app_path(&self, name: &str) -> Result<PathBuf, String> {
|
|
if !is_single_file_name(name) {
|
|
return Err("Invalid app name: expected a single file name".to_string());
|
|
}
|
|
|
|
Ok(self.apps_dir.join(format!("{name}.html")))
|
|
}
|
|
|
|
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 model_config = self.context.model_config_for_session(session_id).await?;
|
|
|
|
let (response, usage) = crate::session_context::with_session_id(
|
|
Some(session_id.to_string()),
|
|
provider.complete(&model_config, &system_prompt, &messages, &tools),
|
|
)
|
|
.await
|
|
.map_err(|e| format!("LLM call failed: {}", e))?;
|
|
|
|
if let (Some(output), Some(max)) = (usage.usage.output_tokens, model_config.max_tokens) {
|
|
if output >= max {
|
|
return Err("App content generation was truncated because the response hit the token limit. Try simplifying your app description.".to_string());
|
|
}
|
|
}
|
|
|
|
extract_tool_response(
|
|
&response,
|
|
"create_app_content",
|
|
&Self::schema::<CreateAppContentResponse>(),
|
|
)
|
|
}
|
|
|
|
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 model_config = self.context.model_config_for_session(session_id).await?;
|
|
|
|
let (response, usage) = crate::session_context::with_session_id(
|
|
Some(session_id.to_string()),
|
|
provider.complete(&model_config, &system_prompt, &messages, &tools),
|
|
)
|
|
.await
|
|
.map_err(|e| format!("LLM call failed: {}", e))?;
|
|
|
|
if let (Some(output), Some(max)) = (usage.usage.output_tokens, model_config.max_tokens) {
|
|
if output >= max {
|
|
return Err("App content update was truncated because the response hit the token limit. Try requesting smaller changes.".to_string());
|
|
}
|
|
}
|
|
|
|
extract_tool_response(
|
|
&response,
|
|
"update_app_content",
|
|
&Self::schema::<UpdateAppContentResponse>(),
|
|
)
|
|
}
|
|
|
|
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),
|
|
deletable: false,
|
|
};
|
|
|
|
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,
|
|
ctx: &ToolCallContext,
|
|
name: &str,
|
|
arguments: Option<JsonObject>,
|
|
_cancel_token: CancellationToken,
|
|
) -> Result<CallToolResult, Error> {
|
|
let session_id = &ctx.session_id;
|
|
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::new(vec![ResourceContents::text(
|
|
html, uri,
|
|
)]))
|
|
}
|
|
|
|
fn get_info(&self) -> Option<&InitializeResult> {
|
|
Some(&self.info)
|
|
}
|
|
}
|
|
|
|
fn schema<T: JsonSchema>() -> JsonObject {
|
|
let mut obj = serde_json::to_value(schema_for!(T))
|
|
.map(|v| v.as_object().unwrap().clone())
|
|
.expect("valid schema");
|
|
// Ensure properties key exists (required by OpenAI-compatible APIs)
|
|
obj.entry("properties")
|
|
.or_insert_with(|| serde_json::json!({}));
|
|
obj
|
|
}
|
|
|
|
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 is_single_file_name(name: &str) -> bool {
|
|
if name.contains(['/', '\\']) || has_windows_drive_prefix(name) {
|
|
return false;
|
|
}
|
|
|
|
let mut components = Path::new(name).components();
|
|
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
|
|
}
|
|
|
|
fn has_windows_drive_prefix(name: &str) -> bool {
|
|
let bytes = name.as_bytes();
|
|
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
|
|
}
|
|
|
|
fn extract_tool_response<T: serde::de::DeserializeOwned>(
|
|
response: &Message,
|
|
tool_name: &str,
|
|
tool_schema: &JsonObject,
|
|
) -> Result<T, String> {
|
|
let schema_value = serde_json::Value::Object(tool_schema.clone());
|
|
|
|
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")?;
|
|
|
|
let coerced = coerce_tool_arguments(Some(params.clone()), &schema_value)
|
|
.unwrap_or_else(|| params.clone());
|
|
|
|
return serde_json::from_value(serde_json::Value::Object(coerced))
|
|
.map_err(|e| format!("Failed to parse tool response: {}", e));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(format!("LLM did not call the required tool: {}", tool_name))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::session::SessionManager;
|
|
use std::path::Path;
|
|
|
|
fn test_client(apps_dir: PathBuf) -> AppsManagerClient {
|
|
AppsManagerClient {
|
|
info: AppsManagerClient::create_info(),
|
|
context: PlatformExtensionContext {
|
|
extension_manager: None,
|
|
session_manager: Arc::new(SessionManager::new(apps_dir.join("sessions"))),
|
|
session: None,
|
|
use_login_shell_path: false,
|
|
},
|
|
apps_dir,
|
|
}
|
|
}
|
|
|
|
fn test_app(name: &str) -> GooseApp {
|
|
GooseApp {
|
|
resource: McpAppResource {
|
|
uri: format!("ui://apps/{name}"),
|
|
name: name.to_string(),
|
|
description: Some("Test app".to_string()),
|
|
mime_type: "text/html;profile=mcp-app".to_string(),
|
|
text: Some("<html><body>test</body></html>".to_string()),
|
|
blob: None,
|
|
meta: None,
|
|
},
|
|
mcp_servers: vec![EXTENSION_NAME.to_string()],
|
|
window_props: None,
|
|
prd: None,
|
|
deletable: true,
|
|
}
|
|
}
|
|
|
|
fn invalid_app_names(temp_dir: &Path) -> Vec<String> {
|
|
vec![
|
|
"../outside".to_string(),
|
|
"nested/outside".to_string(),
|
|
"nested/".to_string(),
|
|
"nested/.".to_string(),
|
|
r"nested\outside".to_string(),
|
|
temp_dir.join("absolute").to_string_lossy().into_owned(),
|
|
"C:outside".to_string(),
|
|
]
|
|
}
|
|
|
|
fn assert_invalid_name(error: String) {
|
|
assert!(
|
|
error.contains("Invalid app name"),
|
|
"expected app-name validation error, got: {error}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_and_resource_read_reject_unsafe_app_names() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let apps_dir = temp.path().join("apps");
|
|
fs::create_dir_all(&apps_dir).unwrap();
|
|
let client = test_client(apps_dir);
|
|
fs::write(
|
|
temp.path().join("outside.html"),
|
|
test_app("outside").to_html().unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
for name in invalid_app_names(temp.path()) {
|
|
assert_invalid_name(client.load_app(&name).unwrap_err());
|
|
|
|
let result = client
|
|
.read_resource(
|
|
"session",
|
|
&format!("ui://apps/{name}"),
|
|
CancellationToken::new(),
|
|
)
|
|
.await;
|
|
assert!(
|
|
result.is_err(),
|
|
"resource read accepted unsafe name: {name}"
|
|
);
|
|
}
|
|
|
|
client.save_app(&test_app("legitimate-app")).unwrap();
|
|
assert!(client.load_app("legitimate-app").is_ok());
|
|
assert!(client
|
|
.read_resource(
|
|
"session",
|
|
"ui://apps/legitimate-app",
|
|
CancellationToken::new(),
|
|
)
|
|
.await
|
|
.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_generated_save_rejects_unsafe_app_names() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let apps_dir = temp.path().join("apps");
|
|
fs::create_dir_all(&apps_dir).unwrap();
|
|
let client = test_client(apps_dir);
|
|
|
|
for name in invalid_app_names(temp.path()) {
|
|
assert_invalid_name(client.save_app(&test_app(&name)).unwrap_err());
|
|
}
|
|
|
|
client.save_app(&test_app("legitimate-app")).unwrap();
|
|
assert_eq!(
|
|
client.load_app("legitimate-app").unwrap().resource.name,
|
|
"legitimate-app"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_rejects_unsafe_app_names() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let apps_dir = temp.path().join("apps");
|
|
fs::create_dir_all(&apps_dir).unwrap();
|
|
let client = test_client(apps_dir);
|
|
let outside_path = temp.path().join("outside.html");
|
|
fs::write(&outside_path, "outside").unwrap();
|
|
|
|
for name in invalid_app_names(temp.path()) {
|
|
assert_invalid_name(client.delete_app(&name).unwrap_err());
|
|
}
|
|
|
|
assert!(outside_path.exists());
|
|
client.save_app(&test_app("legitimate-app")).unwrap();
|
|
client.delete_app("legitimate-app").unwrap();
|
|
assert!(!client.apps_dir.join("legitimate-app.html").exists());
|
|
}
|
|
}
|