From a1c97f787e5ee231f7411628efba28fb7c28e26a Mon Sep 17 00:00:00 2001 From: Gabriel Gordon-Hall Date: Tue, 1 Jul 2025 13:47:35 +0100 Subject: [PATCH] feat: configure global MCP server settings in-app (#11) * wip: basic implementation with claude support * edit existing MCP config, rather than append * extend implementation to support other executors * simplify backend implementation * lint * fix compile errors * decouple mcp server config from default executor selection * display executor config path in MCP settings box * write whole mcpServer object to config file * fmt * backend fmt * move MCP Server settings to seperate page * lint --------- Co-authored-by: couscous --- backend/src/executor.rs | 58 ++++ backend/src/routes/config.rs | 285 ++++++++++++++++- frontend/src/App.tsx | 2 + frontend/src/components/layout/navbar.tsx | 14 +- frontend/src/pages/McpServers.tsx | 357 ++++++++++++++++++++++ frontend/src/pages/Settings.tsx | 1 + 6 files changed, 713 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/McpServers.tsx diff --git a/backend/src/executor.rs b/backend/src/executor.rs index 7f29990d..4d163a2c 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -247,6 +249,21 @@ pub struct ExecutorConstants { pub executor_labels: Vec, } +impl FromStr for ExecutorConfig { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "echo" => Ok(ExecutorConfig::Echo), + "claude" => Ok(ExecutorConfig::Claude), + "amp" => Ok(ExecutorConfig::Amp), + "gemini" => Ok(ExecutorConfig::Gemini), + "opencode" => Ok(ExecutorConfig::Opencode), + _ => Err(format!("Unknown executor type: {}", s)), + } + } +} + impl ExecutorConfig { pub fn create_executor(&self) -> Box { match self { @@ -257,6 +274,47 @@ impl ExecutorConfig { ExecutorConfig::Opencode => Box::new(OpencodeExecutor), } } + + pub fn config_path(&self) -> Option { + match self { + ExecutorConfig::Echo => None, + ExecutorConfig::Opencode => dirs::home_dir().map(|home| home.join(".opencode.json")), + ExecutorConfig::Claude => dirs::home_dir().map(|home| home.join(".claude.json")), + ExecutorConfig::Amp => { + dirs::config_dir().map(|config| config.join("amp").join("settings.json")) + } + ExecutorConfig::Gemini => { + dirs::home_dir().map(|home| home.join(".gemini").join("settings.json")) + } + } + } + + /// Get the JSON attribute path for MCP servers in the config file + pub fn mcp_attribute_path(&self) -> Option> { + match self { + ExecutorConfig::Echo => None, // Echo doesn't support MCP + ExecutorConfig::Opencode => Some(vec!["mcpServers"]), + ExecutorConfig::Claude => Some(vec!["mcpServers"]), + ExecutorConfig::Amp => Some(vec!["amp", "mcpServers"]), // Nested path for Amp + ExecutorConfig::Gemini => Some(vec!["mcpServers"]), + } + } + + /// Check if this executor supports MCP configuration + pub fn supports_mcp(&self) -> bool { + !matches!(self, ExecutorConfig::Echo) + } + + /// Get the display name for this executor + pub fn display_name(&self) -> &'static str { + match self { + ExecutorConfig::Echo => "Echo (Test Mode)", + ExecutorConfig::Opencode => "Opencode", + ExecutorConfig::Claude => "Claude", + ExecutorConfig::Amp => "Amp", + ExecutorConfig::Gemini => "Gemini", + } + } } /// Stream output from a child process to the database diff --git a/backend/src/routes/config.rs b/backend/src/routes/config.rs index 632c2118..d47557a1 100644 --- a/backend/src/routes/config.rs +++ b/backend/src/routes/config.rs @@ -1,16 +1,18 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use axum::{ - extract::Extension, + extract::{Extension, Query}, response::Json as ResponseJson, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; +use serde_json::Value; +use tokio::{fs, sync::RwLock}; use ts_rs::TS; use crate::{ + executor::ExecutorConfig, models::{ config::{Config, EditorConstants, SoundConstants}, ApiResponse, @@ -23,6 +25,8 @@ pub fn config_router() -> Router { .route("/config", get(get_config)) .route("/config", post(update_config)) .route("/config/constants", get(get_config_constants)) + .route("/mcp-servers", get(get_mcp_servers)) + .route("/mcp-servers", post(update_mcp_servers)) } async fn get_config( @@ -80,3 +84,278 @@ async fn get_config_constants() -> ResponseJson> { message: Some("Config constants retrieved successfully".to_string()), }) } + +#[derive(Debug, Deserialize)] +struct McpServerQuery { + executor: Option, +} + +/// Common logic for resolving executor configuration and validating MCP support +fn resolve_executor_config( + query_executor: Option, + saved_config: &ExecutorConfig, +) -> Result { + let executor_config = match query_executor { + Some(executor_type) => executor_type + .parse::() + .map_err(|e| e.to_string())?, + None => saved_config.clone(), + }; + + if !executor_config.supports_mcp() { + return Err(format!( + "{} executor does not support MCP configuration", + executor_config.display_name() + )); + } + + Ok(executor_config) +} + +async fn get_mcp_servers( + Extension(config): Extension>>, + Query(query): Query, +) -> ResponseJson> { + let saved_config = { + let config = config.read().await; + config.executor.clone() + }; + + let executor_config = match resolve_executor_config(query.executor, &saved_config) { + Ok(config) => config, + Err(message) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(message), + }); + } + }; + + // Get the config file path for this executor + let config_path = match executor_config.config_path() { + Some(path) => path, + None => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Could not determine config file path".to_string()), + }); + } + }; + + match read_mcp_servers_from_config(&config_path, &executor_config).await { + Ok(servers) => { + let response_data = serde_json::json!({ + "servers": servers, + "config_path": config_path.to_string_lossy().to_string() + }); + ResponseJson(ApiResponse { + success: true, + data: Some(response_data), + message: Some("MCP servers retrieved successfully".to_string()), + }) + } + Err(e) => ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to read MCP servers: {}", e)), + }), + } +} + +async fn update_mcp_servers( + Extension(config): Extension>>, + Query(query): Query, + Json(new_servers): Json>, +) -> ResponseJson> { + let saved_config = { + let config = config.read().await; + config.executor.clone() + }; + + let executor_config = match resolve_executor_config(query.executor, &saved_config) { + Ok(config) => config, + Err(message) => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(message), + }); + } + }; + + // Get the config file path for this executor + let config_path = match executor_config.config_path() { + Some(path) => path, + None => { + return ResponseJson(ApiResponse { + success: false, + data: None, + message: Some("Could not determine config file path".to_string()), + }); + } + }; + + match update_mcp_servers_in_config(&config_path, &executor_config, new_servers).await { + Ok(message) => ResponseJson(ApiResponse { + success: true, + data: Some(message.clone()), + message: Some(message), + }), + Err(e) => ResponseJson(ApiResponse { + success: false, + data: None, + message: Some(format!("Failed to update MCP servers: {}", e)), + }), + } +} + +async fn update_mcp_servers_in_config( + file_path: &std::path::Path, + executor_config: &ExecutorConfig, + new_servers: HashMap, +) -> Result> { + // Ensure parent directory exists + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).await?; + } + + // Read existing config file or create empty object if it doesn't exist + let file_content = fs::read_to_string(file_path) + .await + .unwrap_or_else(|_| "{}".to_string()); + let mut config: Value = serde_json::from_str(&file_content)?; + + // Get the attribute path for MCP servers + let mcp_path = executor_config.mcp_attribute_path().unwrap(); + + // Get the current server count for comparison + let old_servers = get_mcp_servers_from_config_path(&config, &mcp_path).len(); + + // Set the MCP servers using the correct attribute path + set_mcp_servers_in_config_path(&mut config, &mcp_path, &new_servers)?; + + // Write the updated config back to file + let updated_content = serde_json::to_string_pretty(&config)?; + fs::write(file_path, updated_content).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) +} + +async fn read_mcp_servers_from_config( + file_path: &std::path::Path, + executor_config: &ExecutorConfig, +) -> Result, Box> { + // Read the config file, return empty if it doesn't exist + let file_content = fs::read_to_string(file_path) + .await + .unwrap_or_else(|_| "{}".to_string()); + let config: Value = serde_json::from_str(&file_content)?; + + // Get the attribute path for MCP servers + let mcp_path = executor_config.mcp_attribute_path().unwrap(); + + // Get the servers using the correct attribute path + let servers = get_mcp_servers_from_config_path(&config, &mcp_path); + + Ok(servers) +} + +/// Helper function to get MCP servers from config using a path +fn get_mcp_servers_from_config_path(config: &Value, path: &[&str]) -> HashMap { + // Special handling for AMP - use flat key structure + if path.len() == 2 && path[0] == "amp" && path[1] == "mcpServers" { + let flat_key = format!("{}.{}", path[0], path[1]); + let current = match config.get(&flat_key) { + 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(), + } + } else { + let mut current = config; + + // Navigate to the target location + 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( + config: &mut Value, + path: &[&str], + servers: &HashMap, +) -> Result<(), Box> { + // Ensure config is an object + if !config.is_object() { + *config = serde_json::json!({}); + } + + // Special handling for AMP - use flat key structure + if path.len() == 2 && path[0] == "amp" && path[1] == "mcpServers" { + let flat_key = format!("{}.{}", path[0], path[1]); + config + .as_object_mut() + .unwrap() + .insert(flat_key, serde_json::to_value(servers)?); + return Ok(()); + } + + let mut current = 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(()) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35f99619..8d9153a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { Projects } from '@/pages/projects'; import { ProjectTasks } from '@/pages/project-tasks'; import { TaskAttemptComparePage } from '@/pages/task-attempt-compare'; import { Settings } from '@/pages/Settings'; +import { McpServers } from '@/pages/McpServers'; import { DisclaimerDialog } from '@/components/DisclaimerDialog'; import { OnboardingDialog } from '@/components/OnboardingDialog'; import { ConfigProvider, useConfig } from '@/components/config-provider'; @@ -131,6 +132,7 @@ function AppContent() { element={} /> } /> + } /> diff --git a/frontend/src/components/layout/navbar.tsx b/frontend/src/components/layout/navbar.tsx index bef9e523..d18f5afe 100644 --- a/frontend/src/components/layout/navbar.tsx +++ b/frontend/src/components/layout/navbar.tsx @@ -1,6 +1,6 @@ import { Link, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { FolderOpen, Settings, HelpCircle } from 'lucide-react'; +import { FolderOpen, Settings, HelpCircle, Server } from 'lucide-react'; import { Logo } from '@/components/logo'; import { SupportDialog } from '@/components/support-dialog'; @@ -26,6 +26,18 @@ export function Navbar() { Projects +