use std::collections::HashMap; use axum::{ Json, Router, body::Body, extract::{Path, Query, State}, http, response::{Json as ResponseJson, Response}, routing::{get, put}, }; use deployment::{Deployment, DeploymentError}; use executors::{ executors::{BaseCodingAgent, StandardCodingAgentExecutor}, mcp_config::{McpConfig, read_agent_config, write_agent_config}, profile::{ExecutorConfigs, ExecutorProfileId}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use services::services::config::{Config, ConfigError, SoundFile, save_config_to_file}; use tokio::fs; use ts_rs::TS; use utils::{assets::config_path, response::ApiResponse}; use crate::{DeploymentImpl, error::ApiError}; pub fn router() -> Router { Router::new() .route("/info", get(get_user_system_info)) .route("/config", put(update_config)) .route("/sounds/{sound}", get(get_sound)) .route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers)) .route("/profiles", get(get_profiles).put(update_profiles)) } #[derive(Debug, Serialize, Deserialize, TS)] pub struct Environment { pub os_type: String, pub os_version: String, pub os_architecture: String, pub bitness: String, } impl Default for Environment { fn default() -> Self { Self::new() } } impl Environment { pub fn new() -> Self { let info = os_info::get(); Environment { os_type: info.os_type().to_string(), os_version: info.version().to_string(), os_architecture: info.architecture().unwrap_or("unknown").to_string(), bitness: info.bitness().to_string(), } } } #[derive(Debug, Serialize, Deserialize, TS)] pub struct UserSystemInfo { pub config: Config, #[serde(flatten)] pub profiles: ExecutorConfigs, pub environment: Environment, } // TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants #[axum::debug_handler] async fn get_user_system_info( State(deployment): State, ) -> ResponseJson> { let config = deployment.config().read().await; let user_system_info = UserSystemInfo { config: config.clone(), profiles: ExecutorConfigs::get_cached(), environment: Environment::new(), }; ResponseJson(ApiResponse::success(user_system_info)) } async fn update_config( State(deployment): State, Json(new_config): Json, ) -> ResponseJson> { let config_path = config_path(); // Get the current analytics_enabled state before updating let old_analytics_enabled = { let config = deployment.config().read().await; config.analytics_enabled }; match save_config_to_file(&new_config, &config_path).await { Ok(_) => { let mut config = deployment.config().write().await; *config = new_config.clone(); drop(config); // If analytics was just enabled (changed from None/false to true), track session_start if new_config.analytics_enabled == Some(true) && old_analytics_enabled != Some(true) { deployment .track_if_analytics_allowed("session_start", serde_json::json!({})) .await; } ResponseJson(ApiResponse::success(new_config)) } Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))), } } async fn get_sound(Path(sound): Path) -> Result { let sound = sound.serve().await.map_err(DeploymentError::Other)?; let response = Response::builder() .status(http::StatusCode::OK) .header( http::header::CONTENT_TYPE, http::HeaderValue::from_static("audio/wav"), ) .body(Body::from(sound.data.into_owned())) .unwrap(); Ok(response) } #[derive(TS, Debug, Deserialize)] pub struct McpServerQuery { executor: BaseCodingAgent, } #[derive(TS, Debug, Serialize, Deserialize)] pub struct GetMcpServerResponse { // servers: HashMap, mcp_config: McpConfig, config_path: String, } #[derive(TS, Debug, Serialize, Deserialize)] pub struct UpdateMcpServersBody { servers: HashMap, } async fn get_mcp_servers( State(_deployment): State, Query(query): Query, ) -> Result>, ApiError> { let coding_agent = ExecutorConfigs::get_cached() .get_coding_agent(&ExecutorProfileId::new(query.executor)) .ok_or(ConfigError::ValidationError( "Executor not found".to_string(), ))?; if !coding_agent.supports_mcp() { return Ok(ResponseJson(ApiResponse::error( "MCP not supported by this executor", ))); } // Resolve supplied config path or agent default let config_path = match coding_agent.default_mcp_config_path() { Some(path) => path, None => { return Ok(ResponseJson(ApiResponse::error( "Could not determine config file path", ))); } }; let mut mcpc = coding_agent.get_mcp_config(); let raw_config = read_agent_config(&config_path, &mcpc).await?; let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path); mcpc.set_servers(servers); Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse { mcp_config: mcpc, config_path: config_path.to_string_lossy().to_string(), }))) } async fn update_mcp_servers( State(_deployment): State, Query(query): Query, Json(payload): Json, ) -> Result>, ApiError> { let profiles = ExecutorConfigs::get_cached(); let agent = profiles .get_coding_agent(&ExecutorProfileId::new(query.executor)) .ok_or(ConfigError::ValidationError( "Executor not found".to_string(), ))?; if !agent.supports_mcp() { return Ok(ResponseJson(ApiResponse::error( "This executor does not support MCP servers", ))); } // Resolve supplied config path or agent default let config_path = match agent.default_mcp_config_path() { Some(path) => path.to_path_buf(), None => { return Ok(ResponseJson(ApiResponse::error( "Could not determine config file path", ))); } }; let mcpc = agent.get_mcp_config(); match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await { Ok(message) => Ok(ResponseJson(ApiResponse::success(message))), Err(e) => Ok(ResponseJson(ApiResponse::error(&format!( "Failed to update MCP servers: {}", e )))), } } async fn update_mcp_servers_in_config( config_path: &std::path::Path, mcpc: &McpConfig, new_servers: HashMap, ) -> Result> { // Ensure parent directory exists if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).await?; } // Read existing config (JSON or TOML depending on agent) let mut config = read_agent_config(config_path, mcpc).await?; // Get the current server count for comparison let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len(); // Set the MCP servers using the correct attribute path set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?; // Write the updated config back to file (JSON or TOML depending on agent) write_agent_config(config_path, mcpc, &config).await?; let new_count = new_servers.len(); let message = match (old_servers, new_count) { (0, 0) => "No MCP servers configured".to_string(), (0, n) => format!("Added {} MCP server(s)", n), (old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new), (old, new) => format!( "Updated MCP server configuration (was {}, now {})", old, new ), }; Ok(message) } /// Helper function to get MCP servers from config using a path fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap { let mut current = raw_config; for part in path { current = match current.get(part) { Some(val) => val, None => return HashMap::new(), }; } // Extract the servers object match current.as_object() { Some(servers) => servers .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), None => HashMap::new(), } } /// Helper function to set MCP servers in config using a path fn set_mcp_servers_in_config_path( raw_config: &mut Value, path: &[String], servers: &HashMap, ) -> Result<(), Box> { // Ensure config is an object if !raw_config.is_object() { *raw_config = serde_json::json!({}); } let mut current = raw_config; // Navigate/create the nested structure (all parts except the last) for part in &path[..path.len() - 1] { if current.get(part).is_none() { current .as_object_mut() .unwrap() .insert(part.to_string(), serde_json::json!({})); } current = current.get_mut(part).unwrap(); if !current.is_object() { *current = serde_json::json!({}); } } // Set the final attribute let final_attr = path.last().unwrap(); current .as_object_mut() .unwrap() .insert(final_attr.to_string(), serde_json::to_value(servers)?); Ok(()) } #[derive(Debug, Serialize, Deserialize)] pub struct ProfilesContent { pub content: String, pub path: String, } async fn get_profiles( State(_deployment): State, ) -> ResponseJson> { let profiles_path = utils::assets::profiles_path(); // Use cached data to ensure consistency with runtime and PUT updates let profiles = ExecutorConfigs::get_cached(); let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| { tracing::error!("Failed to serialize profiles to JSON: {}", e); serde_json::to_string_pretty(&ExecutorConfigs::from_defaults()) .unwrap_or_else(|_| "{}".to_string()) }); ResponseJson(ApiResponse::success(ProfilesContent { content, path: profiles_path.display().to_string(), })) } async fn update_profiles( State(_deployment): State, body: String, ) -> ResponseJson> { // Try to parse as ExecutorProfileConfigs format match serde_json::from_str::(&body) { Ok(executor_profiles) => { // Save the profiles to file match executor_profiles.save_overrides() { Ok(_) => { tracing::info!("Executor profiles saved successfully"); // Reload the cached profiles ExecutorConfigs::reload(); ResponseJson(ApiResponse::success( "Executor profiles updated successfully".to_string(), )) } Err(e) => { tracing::error!("Failed to save executor profiles: {}", e); ResponseJson(ApiResponse::error(&format!( "Failed to save executor profiles: {}", e ))) } } } Err(e) => ResponseJson(ApiResponse::error(&format!( "Invalid executor profiles format: {}", e ))), } }