diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 8eb4d94ebc16..0cb3f38f7fff 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1,6 +1,7 @@ use cliclack::spinner; use console::style; use goose::agents::{extension::Envs, ExtensionConfig}; +use goose::config::extensions::name_to_key; use goose::config::{Config, ConfigError, ExperimentManager, ExtensionEntry, ExtensionManager}; use goose::message::Message; use goose::providers::{create, providers}; @@ -379,7 +380,10 @@ pub fn toggle_extensions_dialog() -> Result<(), Box> { // Update enabled status for each extension for name in extension_status.iter().map(|(name, _)| name) { - ExtensionManager::set_enabled(name, selected.iter().any(|s| s.as_str() == name))?; + ExtensionManager::set_enabled( + &name_to_key(name), + selected.iter().any(|s| s.as_str() == name), + )?; } cliclack::outro("Extension settings updated successfully")?; @@ -630,10 +634,17 @@ pub fn remove_extension_dialog() -> Result<(), Box> { return Ok(()); } + // Filter out only disabled extensions + let disabled_extensions: Vec<_> = extensions + .iter() + .filter(|entry| !entry.enabled) + .map(|entry| (entry.config.name().to_string(), entry.enabled)) + .collect(); + let selected = cliclack::multiselect("Select extensions to remove (note: you can only remove disabled extensions - use \"space\" to toggle and \"enter\" to submit)") .required(false) .items( - &extension_status + &disabled_extensions .iter() .filter(|(_, enabled)| !enabled) .map(|(name, _)| (name, name.as_str(), "")) @@ -642,7 +653,7 @@ pub fn remove_extension_dialog() -> Result<(), Box> { .interact()?; for name in selected { - ExtensionManager::remove(name)?; + ExtensionManager::remove(&name_to_key(name))?; cliclack::outro(format!("Removed {} extension", style(name).green()))?; } diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 21281e5f0847..3f53e81adddb 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -1,5 +1,8 @@ use utoipa::OpenApi; +use goose::agents::extension::Envs; +use goose::agents::ExtensionConfig; +use goose::config::ExtensionEntry; use goose::providers::base::ConfigKey; use goose::providers::base::ProviderMetadata; @@ -12,6 +15,8 @@ use goose::providers::base::ProviderMetadata; super::routes::config_management::read_config, super::routes::config_management::add_extension, super::routes::config_management::remove_extension, + super::routes::config_management::toggle_extension, + super::routes::config_management::get_extensions, super::routes::config_management::update_extension, super::routes::config_management::read_all_config, super::routes::config_management::providers @@ -19,13 +24,17 @@ use goose::providers::base::ProviderMetadata; components(schemas( super::routes::config_management::UpsertConfigQuery, super::routes::config_management::ConfigKeyQuery, - super::routes::config_management::ExtensionQuery, super::routes::config_management::ConfigResponse, super::routes::config_management::ProvidersResponse, super::routes::config_management::ProvidersResponse, super::routes::config_management::ProviderDetails, + super::routes::config_management::ExtensionResponse, + super::routes::config_management::ExtensionQuery, ProviderMetadata, - ConfigKey + ExtensionEntry, + ExtensionConfig, + ConfigKey, + Envs, )) )] pub struct ApiDoc; diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 664f15a02b9f..21b1d9db8b33 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -1,10 +1,15 @@ +use crate::routes::utils::check_provider_configured; +use crate::state::AppState; use axum::routing::put; use axum::{ extract::State, routing::{delete, get, post}, Json, Router, }; +use goose::agents::ExtensionConfig; +use goose::config::extensions::name_to_key; use goose::config::Config; +use goose::config::{ExtensionEntry, ExtensionManager}; use goose::providers::base::ProviderMetadata; use goose::providers::providers as get_providers; use http::{HeaderMap, StatusCode}; @@ -13,9 +18,6 @@ use serde_json::Value; use std::collections::HashMap; use utoipa::ToSchema; -use crate::routes::utils::check_provider_configured; -use crate::state::AppState; - fn verify_secret_key(headers: &HeaderMap, state: &AppState) -> Result { // Verify secret key let secret_key = headers @@ -30,6 +32,18 @@ fn verify_secret_key(headers: &HeaderMap, state: &AppState) -> Result, +} + +#[derive(Deserialize, ToSchema)] +pub struct ExtensionQuery { + pub name: String, + pub config: ExtensionConfig, + pub enabled: bool, +} + #[derive(Deserialize, ToSchema)] pub struct UpsertConfigQuery { pub key: String, @@ -43,12 +57,6 @@ pub struct ConfigKeyQuery { pub is_secret: bool, } -#[derive(Deserialize, ToSchema)] -pub struct ExtensionQuery { - pub name: String, - pub config: Value, -} - #[derive(Serialize, ToSchema)] pub struct ConfigResponse { pub config: HashMap, @@ -155,9 +163,29 @@ pub async fn read_config( } } +#[utoipa::path( + get, + path = "/config/extensions", + responses( + (status = 200, description = "All extensions retrieved successfully", body = ExtensionResponse), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_extensions( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + match ExtensionManager::get_all() { + Ok(extensions) => Ok(Json(ExtensionResponse { extensions })), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } +} + #[utoipa::path( post, - path = "/config/extension", + path = "/config/extensions", request_body = ExtensionQuery, responses( (status = 200, description = "Extension added successfully", body = String), @@ -168,35 +196,23 @@ pub async fn read_config( pub async fn add_extension( State(state): State, headers: HeaderMap, - Json(extension): Json, + Json(extension_query): Json, ) -> Result, StatusCode> { - // Use the helper function to verify the secret key verify_secret_key(&headers, &state)?; - let config = Config::global(); - - // Get current extensions or initialize empty map - let mut extensions: HashMap = config - .get_param("extensions") - .unwrap_or_else(|_| HashMap::new()); - - // Add new extension - extensions.insert(extension.name.clone(), extension.config); - - // Save updated extensions - match config.set_param( - "extensions", - Value::Object(extensions.into_iter().collect()), - ) { - Ok(_) => Ok(Json(format!("Added extension {}", extension.name))), + // Use ExtensionManager to set the extension + match ExtensionManager::set(ExtensionEntry { + enabled: extension_query.enabled, + config: extension_query.config, + }) { + Ok(_) => Ok(Json(format!("Added extension {}", extension_query.name))), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } #[utoipa::path( delete, - path = "/config/extension", - request_body = ConfigKeyQuery, + path = "/config/extensions/{name}", responses( (status = 200, description = "Extension removed successfully", body = String), (status = 404, description = "Extension not found"), @@ -206,100 +222,123 @@ pub async fn add_extension( pub async fn remove_extension( State(state): State, headers: HeaderMap, - Json(query): Json, + axum::extract::Path(name): axum::extract::Path, ) -> Result, StatusCode> { - // Use the helper function to verify the secret key verify_secret_key(&headers, &state)?; - let config = Config::global(); - - // Get current extensions - let mut extensions: HashMap = match config.get_param("extensions") { - Ok(exts) => exts, - Err(_) => return Err(StatusCode::NOT_FOUND), - }; - - // Remove extension if it exists - if extensions.remove(&query.key).is_some() { - // Save updated extensions - match config.set_param( - "extensions", - Value::Object(extensions.into_iter().collect()), - ) { - Ok(_) => Ok(Json(format!("Removed extension {}", query.key))), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } - } else { - Err(StatusCode::NOT_FOUND) + let key = name_to_key(&name); + // Use ExtensionManager to remove the extension + match ExtensionManager::remove(&key) { + Ok(_) => Ok(Json(format!("Removed extension {}", name))), + Err(_) => Err(StatusCode::NOT_FOUND), } } #[utoipa::path( - get, - path = "/config", + put, + path = "/config/extensions/{name}", + request_body = ExtensionQuery, responses( - (status = 200, description = "All configuration values retrieved successfully", body = ConfigResponse) + (status = 200, description = "Extension updated successfully", body = String), + (status = 404, description = "Extension not found"), + (status = 500, description = "Internal server error") ) )] -pub async fn read_all_config( +pub async fn update_extension( State(state): State, headers: HeaderMap, -) -> Result, StatusCode> { - // Use the helper function to verify the secret key + axum::extract::Path(name): axum::extract::Path, + Json(extension_query): Json, +) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let config = Config::global(); + let key = name_to_key(&name); - // Load values from config file - let values = config.load_values().unwrap_or_default(); + // Check if extension exists + let extensions = ExtensionManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - Ok(Json(ConfigResponse { config: values })) + if !extensions.iter().any(|entry| entry.config.key() == key) { + return Err(StatusCode::NOT_FOUND); + } + + // Use ExtensionManager to update the extension + match ExtensionManager::set(ExtensionEntry { + enabled: extension_query.enabled, + config: extension_query.config, + }) { + Ok(_) => Ok(Json(format!("Updated extension {}", extension_query.name))), + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + } } #[utoipa::path( - put, - path = "/config/extension", - request_body = ExtensionQuery, + post, + path = "/extensions/{name}/toggle", responses( - (status = 200, description = "Extension configuration updated successfully", body = String), + (status = 200, description = "Extension toggled successfully", body = String), (status = 404, description = "Extension not found"), (status = 500, description = "Internal server error") ) )] -pub async fn update_extension( +pub async fn toggle_extension( State(state): State, headers: HeaderMap, - Json(extension): Json, + axum::extract::Path(name): axum::extract::Path, ) -> Result, StatusCode> { - // Use the helper function to verify the secret key verify_secret_key(&headers, &state)?; - let config = Config::global(); + let key = name_to_key(&name); - // Get current extensions - let mut extensions: HashMap = match config.get_param("extensions") { - Ok(exts) => exts, - Err(_) => return Err(StatusCode::NOT_FOUND), - }; + // Get the extension + let extensions = ExtensionManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Check if extension exists - if !extensions.contains_key(&extension.name) { - return Err(StatusCode::NOT_FOUND); - } + let extension = extensions + .iter() + .find(|e| e.config.key() == key) + .ok_or(StatusCode::NOT_FOUND)?; - // Update extension configuration - extensions.insert(extension.name.clone(), extension.config); + // Create a new entry with toggled enabled state + let updated_entry = ExtensionEntry { + enabled: !extension.enabled, + config: extension.config.clone(), + }; - // Save updated extensions - match config.set_param( - "extensions", - Value::Object(extensions.into_iter().collect()), - ) { - Ok(_) => Ok(Json(format!("Updated extension {}", extension.name))), + // Update using ExtensionManager + match ExtensionManager::set(updated_entry) { + Ok(_) => { + let status = if !extension.enabled { + "enabled" + } else { + "disabled" + }; + Ok(Json(format!("Extension {} {}", name, status))) + } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } +#[utoipa::path( + get, + path = "/config", + responses( + (status = 200, description = "All configuration values retrieved successfully", body = ConfigResponse) + ) +)] +pub async fn read_all_config( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + // Use the helper function to verify the secret key + verify_secret_key(&headers, &state)?; + + let config = Config::global(); + + // Load values from config file + let values = config.load_values().unwrap_or_default(); + + Ok(Json(ConfigResponse { config: values })) +} + // Modified providers function using the new response type #[utoipa::path( get, @@ -341,9 +380,11 @@ pub fn routes(state: AppState) -> Router { .route("/config/upsert", post(upsert_config)) .route("/config/remove", post(remove_config)) .route("/config/read", post(read_config)) - .route("/config/extension", post(add_extension)) - .route("/config/extension", put(update_extension)) - .route("/config/extension", delete(remove_extension)) + .route("/config/extensions", get(get_extensions)) + .route("/config/extensions", post(add_extension)) + .route("/config/extensions/:name", put(update_extension)) + .route("/config/extensions/:name", delete(remove_extension)) + .route("/extensions/:name/toggle", post(toggle_extension)) .route("/config/providers", get(providers)) .with_state(state) } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index d1a50f338fea..551ed18b287e 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -60,7 +60,7 @@ serde_yaml = "0.9.34" once_cell = "1.20.2" etcetera = "0.8.0" rand = "0.8.5" -utoipa = { version = "4.1" } +utoipa = "4.1" # For Bedrock provider aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } diff --git a/crates/goose/src/agents/capabilities.rs b/crates/goose/src/agents/capabilities.rs index 37b29d3cfc78..18105c248b3c 100644 --- a/crates/goose/src/agents/capabilities.rs +++ b/crates/goose/src/agents/capabilities.rs @@ -171,7 +171,7 @@ impl Capabilities { .await .map_err(|e| ExtensionError::Initialization(config.clone(), e))?; - let sanitized_name = normalize(config.name().to_string()); + let sanitized_name = normalize(config.key().to_string()); // Store instructions if provided if let Some(instructions) = init_result.instructions { diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 6aeafea0348c..27650fa183e0 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -3,8 +3,10 @@ use std::collections::HashMap; use mcp_client::client::Error as ClientError; use serde::{Deserialize, Serialize}; use thiserror::Error; +use utoipa::ToSchema; use crate::config; +use crate::config::extensions::name_to_key; /// Errors from Extension operation #[derive(Error, Debug)] @@ -21,7 +23,7 @@ pub enum ExtensionError { pub type ExtensionResult = Result; -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, ToSchema)] pub struct Envs { /// A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host #[serde(default)] @@ -43,7 +45,7 @@ impl Envs { } /// Represents the different types of MCP extensions that can be added to the manager -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] #[serde(tag = "type")] pub enum ExtensionConfig { /// Server-sent events client with a URI endpoint @@ -130,13 +132,19 @@ impl ExtensionConfig { } } + pub fn key(&self) -> String { + let name = self.name(); + name_to_key(&name) + } + /// Get the extension name regardless of variant - pub fn name(&self) -> &str { + pub fn name(&self) -> String { match self { Self::Sse { name, .. } => name, Self::Stdio { name, .. } => name, Self::Builtin { name, .. } => name, } + .to_string() } } diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 91b68caeef87..d044f12c79f4 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -3,23 +3,28 @@ use crate::agents::ExtensionConfig; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use utoipa::ToSchema; pub const DEFAULT_EXTENSION: &str = "developer"; pub const DEFAULT_EXTENSION_TIMEOUT: u64 = 300; -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, ToSchema)] pub struct ExtensionEntry { pub enabled: bool, #[serde(flatten)] pub config: ExtensionConfig, } +pub fn name_to_key(name: &str) -> String { + name.to_string() +} + /// Extension configuration management pub struct ExtensionManager; impl ExtensionManager { - /// Get the extension configuration if enabled - pub fn get_config(name: &str) -> Result> { + /// Get the extension configuration if enabled -- uses key + pub fn get_config(key: &str) -> Result> { let config = Config::global(); // Try to get the extension entry @@ -28,7 +33,7 @@ impl ExtensionManager { Err(super::ConfigError::NotFound(_)) => { // Initialize with default developer extension let defaults = HashMap::from([( - DEFAULT_EXTENSION.to_string(), + name_to_key(DEFAULT_EXTENSION), // Use key format for top-level key in config ExtensionEntry { enabled: true, config: ExtensionConfig::Builtin { @@ -43,7 +48,7 @@ impl ExtensionManager { Err(e) => return Err(e.into()), }; - Ok(extensions.get(name).and_then(|entry| { + Ok(extensions.get(key).and_then(|entry| { if entry.enabled { Some(entry.config.clone()) } else { @@ -60,33 +65,35 @@ impl ExtensionManager { .get_param("extensions") .unwrap_or_else(|_| HashMap::new()); - extensions.insert(entry.config.name().parse()?, entry); + let key = entry.config.key(); + + extensions.insert(key, entry); config.set_param("extensions", serde_json::to_value(extensions)?)?; Ok(()) } - /// Remove an extension configuration - pub fn remove(name: &str) -> Result<()> { + /// Remove an extension configuration -- uses the key + pub fn remove(key: &str) -> Result<()> { let config = Config::global(); let mut extensions: HashMap = config .get_param("extensions") .unwrap_or_else(|_| HashMap::new()); - extensions.remove(name); + extensions.remove(key); config.set_param("extensions", serde_json::to_value(extensions)?)?; Ok(()) } - /// Enable or disable an extension - pub fn set_enabled(name: &str, enabled: bool) -> Result<()> { + /// Enable or disable an extension -- uses key + pub fn set_enabled(key: &str, enabled: bool) -> Result<()> { let config = Config::global(); let mut extensions: HashMap = config .get_param("extensions") .unwrap_or_else(|_| HashMap::new()); - if let Some(entry) = extensions.get_mut(name) { + if let Some(entry) = extensions.get_mut(key) { entry.enabled = enabled; config.set_param("extensions", serde_json::to_value(extensions)?)?; } @@ -109,16 +116,17 @@ impl ExtensionManager { .unwrap_or_else(|_| get_keys(Default::default()))) } - /// Check if an extension is enabled - pub fn is_enabled(name: &str) -> Result { + /// Check if an extension is enabled - FIXED to use key + pub fn is_enabled(key: &str) -> Result { let config = Config::global(); let extensions: HashMap = config .get_param("extensions") .unwrap_or_else(|_| HashMap::new()); - Ok(extensions.get(name).map(|e| e.enabled).unwrap_or(false)) + Ok(extensions.get(key).map(|e| e.enabled).unwrap_or(false)) } } + fn get_keys(entries: HashMap) -> Vec { entries.into_keys().collect() } diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index 94191d90b01f..dee27c216df7 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -1,6 +1,6 @@ mod base; mod experiments; -mod extensions; +pub mod extensions; pub use crate::agents::ExtensionConfig; pub use base::{Config, ConfigError, APP_STRATEGY}; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 6df1387ea80e..491f9c995439 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -33,7 +33,28 @@ } } }, - "/config/extension": { + "/config/extensions": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "get_extensions", + "responses": { + "200": { + "description": "All extensions retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + }, "post": { "tags": [ "super::routes::config_management" @@ -67,12 +88,24 @@ "description": "Internal server error" } } - }, + } + }, + "/config/extensions/{name}": { "put": { "tags": [ "super::routes::config_management" ], "operationId": "update_extension", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -85,7 +118,7 @@ }, "responses": { "200": { - "description": "Extension configuration updated successfully", + "description": "Extension updated successfully", "content": { "text/plain": { "schema": { @@ -107,16 +140,16 @@ "super::routes::config_management" ], "operationId": "remove_extension", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfigKeyQuery" - } + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Extension removed successfully", @@ -259,6 +292,42 @@ } } } + }, + "/extensions/{name}/toggle": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "toggle_extension", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Extension toggled successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Extension not found" + }, + "500": { + "description": "Internal server error" + } + } + } } }, "components": { @@ -313,19 +382,171 @@ } } }, + "Envs": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "A map of environment variables to set, e.g. API_KEY -> some_secret, HOST -> host" + } + }, + "ExtensionConfig": { + "oneOf": [ + { + "type": "object", + "description": "Server-sent events client with a URI endpoint", + "required": [ + "name", + "uri", + "type" + ], + "properties": { + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "sse" + ] + }, + "uri": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "Standard I/O client with command and arguments", + "required": [ + "name", + "cmd", + "args", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cmd": { + "type": "string" + }, + "envs": { + "$ref": "#/components/schemas/Envs" + }, + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "stdio" + ] + } + } + }, + { + "type": "object", + "description": "Built-in extension that is part of the goose binary", + "required": [ + "name", + "type" + ], + "properties": { + "name": { + "type": "string", + "description": "The name used to identify this extension" + }, + "timeout": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "builtin" + ] + } + } + } + ], + "description": "Represents the different types of MCP extensions that can be added to the manager", + "discriminator": { + "propertyName": "type" + } + }, + "ExtensionEntry": { + "allOf": [ + { + "$ref": "#/components/schemas/ExtensionConfig" + }, + { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + } + ] + }, "ExtensionQuery": { "type": "object", "required": [ "name", - "config" + "config", + "enabled" ], "properties": { - "config": {}, + "config": { + "$ref": "#/components/schemas/ExtensionConfig" + }, + "enabled": { + "type": "boolean" + }, "name": { "type": "string" } } }, + "ExtensionResponse": { + "type": "object", + "required": [ + "extensions" + ], + "properties": { + "extensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionEntry" + } + } + } + }, "ProviderDetails": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 652926772e99..c8057f9633c0 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { ReadAllConfigData, ReadAllConfigResponse, RemoveExtensionData, RemoveExtensionResponse, AddExtensionData, AddExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse } from './types.gen'; +import type { ReadAllConfigData, ReadAllConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, UpdateExtensionData, UpdateExtensionResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ToggleExtensionData, ToggleExtensionResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -25,20 +25,16 @@ export const readAllConfig = (options?: Op }); }; -export const removeExtension = (options: Options) => { - return (options.client ?? _heyApiClient).delete({ - url: '/config/extension', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } +export const getExtensions = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + url: '/config/extensions', + ...options }); }; export const addExtension = (options: Options) => { return (options.client ?? _heyApiClient).post({ - url: '/config/extension', + url: '/config/extensions', ...options, headers: { 'Content-Type': 'application/json', @@ -47,9 +43,16 @@ export const addExtension = (options: Opti }); }; +export const removeExtension = (options: Options) => { + return (options.client ?? _heyApiClient).delete({ + url: '/config/extensions/{name}', + ...options + }); +}; + export const updateExtension = (options: Options) => { return (options.client ?? _heyApiClient).put({ - url: '/config/extension', + url: '/config/extensions/{name}', ...options, headers: { 'Content-Type': 'application/json', @@ -96,4 +99,11 @@ export const upsertConfig = (options: Opti ...options?.headers } }); +}; + +export const toggleExtension = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/extensions/{name}/toggle', + ...options + }); }; \ No newline at end of file diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 961528510b1c..1889cfe1c2e3 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -16,11 +16,57 @@ export type ConfigResponse = { config: {}; }; +export type Envs = { + [key: string]: string; +}; + +/** + * Represents the different types of MCP extensions that can be added to the manager + */ +export type ExtensionConfig = { + envs?: Envs; + /** + * The name used to identify this extension + */ + name: string; + timeout?: number | null; + type: 'sse'; + uri: string; +} | { + args: Array; + cmd: string; + envs?: Envs; + /** + * The name used to identify this extension + */ + name: string; + timeout?: number | null; + type: 'stdio'; +} | { + /** + * The name used to identify this extension + */ + name: string; + timeout?: number | null; + type: 'builtin'; +}; + +export type ExtensionEntry = ExtensionConfig & { + type?: 'ExtensionEntry'; +} & { + enabled: boolean; +}; + export type ExtensionQuery = { - config: unknown; + config: ExtensionConfig; + enabled: boolean; name: string; }; +export type ExtensionResponse = { + extensions: Array; +}; + export type ProviderDetails = { /** * Indicates whether the provider is fully configured @@ -94,38 +140,34 @@ export type ReadAllConfigResponses = { export type ReadAllConfigResponse = ReadAllConfigResponses[keyof ReadAllConfigResponses]; -export type RemoveExtensionData = { - body: ConfigKeyQuery; +export type GetExtensionsData = { + body?: never; path?: never; query?: never; - url: '/config/extension'; + url: '/config/extensions'; }; -export type RemoveExtensionErrors = { - /** - * Extension not found - */ - 404: unknown; +export type GetExtensionsErrors = { /** * Internal server error */ 500: unknown; }; -export type RemoveExtensionResponses = { +export type GetExtensionsResponses = { /** - * Extension removed successfully + * All extensions retrieved successfully */ - 200: string; + 200: ExtensionResponse; }; -export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses]; +export type GetExtensionsResponse = GetExtensionsResponses[keyof GetExtensionsResponses]; export type AddExtensionData = { body: ExtensionQuery; path?: never; query?: never; - url: '/config/extension'; + url: '/config/extensions'; }; export type AddExtensionErrors = { @@ -148,11 +190,42 @@ export type AddExtensionResponses = { export type AddExtensionResponse = AddExtensionResponses[keyof AddExtensionResponses]; +export type RemoveExtensionData = { + body?: never; + path: { + name: string; + }; + query?: never; + url: '/config/extensions/{name}'; +}; + +export type RemoveExtensionErrors = { + /** + * Extension not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type RemoveExtensionResponses = { + /** + * Extension removed successfully + */ + 200: string; +}; + +export type RemoveExtensionResponse = RemoveExtensionResponses[keyof RemoveExtensionResponses]; + export type UpdateExtensionData = { body: ExtensionQuery; - path?: never; + path: { + name: string; + }; query?: never; - url: '/config/extension'; + url: '/config/extensions/{name}'; }; export type UpdateExtensionErrors = { @@ -168,7 +241,7 @@ export type UpdateExtensionErrors = { export type UpdateExtensionResponses = { /** - * Extension configuration updated successfully + * Extension updated successfully */ 200: string; }; @@ -262,6 +335,35 @@ export type UpsertConfigResponses = { export type UpsertConfigResponse = UpsertConfigResponses[keyof UpsertConfigResponses]; +export type ToggleExtensionData = { + body?: never; + path: { + name: string; + }; + query?: never; + url: '/extensions/{name}/toggle'; +}; + +export type ToggleExtensionErrors = { + /** + * Extension not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ToggleExtensionResponses = { + /** + * Extension toggled successfully + */ + 200: string; +}; + +export type ToggleExtensionResponse = ToggleExtensionResponses[keyof ToggleExtensionResponses]; + export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}); }; \ No newline at end of file diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 4afe78633466..803796437826 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -4,6 +4,8 @@ import { readConfig, removeConfig, upsertConfig, + getExtensions as apiGetExtensions, + toggleExtension as apiToggleExtension, addExtension as apiAddExtension, removeExtension as apiRemoveExtension, updateExtension as apiUpdateExtension, @@ -14,8 +16,11 @@ import type { ConfigResponse, UpsertConfigQuery, ConfigKeyQuery, - ExtensionQuery, + ExtensionResponse, + ExtensionEntry, ProviderDetails, + ExtensionQuery, + ExtensionConfig, } from '../api/types.gen'; // Initialize client configuration @@ -33,10 +38,12 @@ interface ConfigContextType { upsert: (key: string, value: unknown, is_secret: boolean) => Promise; read: (key: string, is_secret: boolean) => Promise; remove: (key: string, is_secret: boolean) => Promise; - addExtension: (name: string, config: unknown) => Promise; - updateExtension: (name: string, config: unknown) => Promise; + addExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; + updateExtension: (name: string, config: ExtensionConfig, enabled: boolean) => Promise; + toggleExtension: (name: string) => Promise; removeExtension: (name: string) => Promise; getProviders: (b: boolean) => Promise; + getExtensions: (b: boolean) => Promise } interface ConfigProviderProps { @@ -48,6 +55,7 @@ const ConfigContext = createContext(undefined); export const ConfigProvider: React.FC = ({ children }) => { const [config, setConfig] = useState({}); const [providersList, setProvidersList] = useState([]); + const [extensionsList, setExtensionsList] = useState([]); useEffect(() => { // Load all configuration data and providers on mount @@ -63,6 +71,15 @@ export const ConfigProvider: React.FC = ({ children }) => { } catch (error) { console.error('Failed to load providers:', error); } + + // Load extensions + try { + const extensionsResponse = await apiGetExtensions() + setExtensionsList(extensionsResponse.data.extensions) + } catch (error) { + console.error('Failed to load extensions:', error) + } + })(); }, []); @@ -99,8 +116,8 @@ export const ConfigProvider: React.FC = ({ children }) => { await reloadConfig(); }; - const addExtension = async (name: string, config: unknown) => { - const query: ExtensionQuery = { name, config }; + const addExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => { + const query: ExtensionQuery = { name, config, enabled }; await apiAddExtension({ body: query, }); @@ -108,21 +125,24 @@ export const ConfigProvider: React.FC = ({ children }) => { }; const removeExtension = async (name: string) => { - const query: ConfigKeyQuery = { key: name, is_secret: false }; - await apiRemoveExtension({ - body: query, - }); + await apiRemoveExtension({path: {name: name}}); await reloadConfig(); }; - const updateExtension = async (name: string, config: unknown) => { - const query: ExtensionQuery = { name, config }; + const updateExtension = async (name: string, config: ExtensionConfig, enabled: boolean) => { + const query: ExtensionQuery = { name, config, enabled }; await apiUpdateExtension({ body: query, + path: {name: name} }); await reloadConfig(); }; + const toggleExtension = async (name: string) => { + await apiToggleExtension({path: {name: name}}); + await reloadConfig(); + }; + const getProviders = async (forceRefresh = false): Promise => { if (forceRefresh || providersList.length === 0) { // If a refresh is forced or we don't have providers yet @@ -134,6 +154,18 @@ export const ConfigProvider: React.FC = ({ children }) => { return providersList; }; + const getExtensions = async (forceRefresh = false): Promise => { + if (forceRefresh || extensionsList.length === 0) { + // If a refresh is forced, or we don't have providers yet + const response = await apiGetExtensions(); + const extensionResponse: ExtensionResponse = response.data + setExtensionsList(extensionResponse.extensions); + return extensionResponse.extensions; + } + // Otherwise return the cached providers + return extensionsList; + }; + const contextValue = useMemo( () => ({ config, @@ -144,7 +176,9 @@ export const ConfigProvider: React.FC = ({ children }) => { addExtension, updateExtension, removeExtension, + toggleExtension, getProviders, + getExtensions, }), [config, providersList] ); // Functions don't need to be dependencies as they don't change diff --git a/ui/desktop/src/components/settings_v2/SettingsView.tsx b/ui/desktop/src/components/settings_v2/SettingsView.tsx index 67f492b45093..1cbdb4e8a14c 100644 --- a/ui/desktop/src/components/settings_v2/SettingsView.tsx +++ b/ui/desktop/src/components/settings_v2/SettingsView.tsx @@ -6,7 +6,7 @@ import { useConfig } from '../ConfigContext'; import { Button } from '../ui/button'; import { Plus } from 'lucide-react'; import { Gear } from '../icons/Gear'; -import ExtensionsSection from './ExtensionsSection'; +import ExtensionsSection from './extensions/ExtensionsSection'; interface ModelOption { id: string; diff --git a/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx similarity index 96% rename from ui/desktop/src/components/settings_v2/ExtensionsSection.tsx rename to ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx index 4c777ec6602f..344fcd93b198 100644 --- a/ui/desktop/src/components/settings_v2/ExtensionsSection.tsx +++ b/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx @@ -1,14 +1,14 @@ import React, { useEffect, useState } from 'react'; -import { Button } from '../ui/button'; -import { Switch } from '../ui/switch'; +import { Button } from '../../ui/button'; +import { Switch } from '../../ui/switch'; import { Plus, X } from 'lucide-react'; -import { Gear } from '../icons/Gear'; -import { GPSIcon } from '../ui/icons'; -import { useConfig } from '../ConfigContext'; -import Modal from '../Modal'; -import { Input } from '../ui/input'; +import { Gear } from '../../icons/Gear'; +import { GPSIcon } from '../../ui/icons'; +import { useConfig } from '../../ConfigContext'; +import Modal from '../../Modal'; +import { Input } from '../../ui/input'; import Select from 'react-select'; -import { createDarkSelectStyles, darkSelectTheme } from '../ui/select-styles'; +import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles'; interface ExtensionConfig { args?: string[]; @@ -16,7 +16,7 @@ interface ExtensionConfig { enabled: boolean; envs?: Record; name: string; - type: 'stdio' | 'sse'; + type: 'stdio' | 'sse' | 'builtin'; } interface ExtensionItem { @@ -50,7 +50,7 @@ const getSubtitle = (config: ExtensionConfig): string => { }; export default function ExtensionsSection() { - const { config, updateExtension, addExtension } = useConfig(); + const { config, read, updateExtension, addExtension } = useConfig(); const [extensions, setExtensions] = useState([]); const [selectedExtension, setSelectedExtension] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -74,8 +74,9 @@ export default function ExtensionsSection() { }); useEffect(() => { - if (config.extensions) { - const extensionItems: ExtensionItem[] = Object.entries(config.extensions).map( + const extensions = read('extensions', false) + if (extensions) { + const extensionItems: ExtensionItem[] = Object.entries(extensions).map( ([name, ext]) => { const extensionConfig = ext as ExtensionConfig; return { @@ -90,7 +91,7 @@ export default function ExtensionsSection() { ); setExtensions(extensionItems); } - }, [config.extensions]); + }, [read]); useEffect(() => { if (selectedExtension) { diff --git a/ui/desktop/src/components/settings_v2/extensions/utils.tsx b/ui/desktop/src/components/settings_v2/extensions/utils.tsx new file mode 100644 index 000000000000..c51591b291fc --- /dev/null +++ b/ui/desktop/src/components/settings_v2/extensions/utils.tsx @@ -0,0 +1,121 @@ +// TODO: copied this from old code + +// import {View} from '../../../App' +// import {SettingsViewOptions} from "../SettingsView"; +// import {toast} from "react-toastify"; +// +// +// export async function addExtensionFromDeepLink( +// url: string, +// setView: (view: View, options: SettingsViewOptions) => void +// ) { +// if (!url.startsWith('goose://extension')) { +// handleError( +// 'Failed to install extension: Invalid URL: URL must use the goose://extension scheme' +// ); +// return; +// } +// +// const parsedUrl = new URL(url); +// +// if (parsedUrl.protocol !== 'goose:') { +// handleError( +// 'Failed to install extension: Invalid protocol: URL must use the goose:// scheme', +// true +// ); +// } +// +// // Check that all required fields are present and not empty +// const requiredFields = ['name', 'description']; +// +// for (const field of requiredFields) { +// const value = parsedUrl.searchParams.get(field); +// if (!value || value.trim() === '') { +// handleError( +// `Failed to install extension: The link is missing required field '${field}'`, +// true +// ); +// } +// } +// +// const cmd = parsedUrl.searchParams.get('cmd'); +// if (!cmd) { +// handleError("Failed to install extension: Missing required 'cmd' parameter in the URL", true); +// } +// +// // Validate that the command is one of the allowed commands +// const allowedCommands = ['npx', 'uvx', 'goosed']; +// if (!allowedCommands.includes(cmd)) { +// handleError( +// `Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`, +// true +// ); +// } +// +// // Check for security risk with npx -c command +// const args = parsedUrl.searchParams.getAll('arg'); +// if (cmd === 'npx' && args.includes('-c')) { +// handleError( +// 'Failed to install extension: npx with -c argument can lead to code injection', +// true +// ); +// } +// +// const envList = parsedUrl.searchParams.getAll('env'); +// const id = parsedUrl.searchParams.get('id'); +// const name = parsedUrl.searchParams.get('name'); +// const description = parsedUrl.searchParams.get('description'); +// const timeout = parsedUrl.searchParams.get('timeout'); +// +// // split env based on delimiter to a map +// const envs = envList.reduce( +// (acc, env) => { +// const [key, value] = env.split('='); +// acc[key] = value; +// return acc; +// }, +// {} as Record +// ); +// +// // Create a ExtensionConfig from the URL parameters +// // Parse timeout if provided, otherwise use default +// const parsedTimeout = timeout ? parseInt(timeout, 10) : null; +// +// const extensionConfig: ExtensionConfig = { +// id, +// name, +// type: 'stdio', +// cmd, +// args, +// description, +// enabled: true, +// env_keys: Object.keys(envs).length > 0 ? Object.keys(envs) : [], +// timeout: +// parsedTimeout !== null && !isNaN(parsedTimeout) && Number.isInteger(parsedTimeout) +// ? parsedTimeout +// : DEFAULT_EXTENSION_TIMEOUT, +// }; +// +// // Store the extension config regardless of env vars status +// storeExtensionConfig(extensionConfig); +// +// // Check if extension requires env vars and go to settings if so +// if (envVarsRequired(extensionConfig)) { +// console.log('Environment variables required, redirecting to settings'); +// setView('settings', { extensionId: extensionConfig.id, showEnvVars: true }); +// return; +// } +// +// // If no env vars are required, proceed with extending Goosed +// await addExtension(extensionConfig); +// } +// +// function handleError(message: string, shouldThrow = false): void { +// toast.error(message); +// console.error(message); +// if (shouldThrow) { +// throw new Error(message); +// } +// } + +// TODO: when rust app starts, add built-in extensions to config.yaml if they aren't there already