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 <couscous@runner.com>
This commit is contained in:
committed by
GitHub
parent
7620ba60fa
commit
a1c97f787e
@@ -1,3 +1,5 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
@@ -247,6 +249,21 @@ pub struct ExecutorConstants {
|
|||||||
pub executor_labels: Vec<String>,
|
pub executor_labels: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromStr for ExecutorConfig {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
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 {
|
impl ExecutorConfig {
|
||||||
pub fn create_executor(&self) -> Box<dyn Executor> {
|
pub fn create_executor(&self) -> Box<dyn Executor> {
|
||||||
match self {
|
match self {
|
||||||
@@ -257,6 +274,47 @@ impl ExecutorConfig {
|
|||||||
ExecutorConfig::Opencode => Box::new(OpencodeExecutor),
|
ExecutorConfig::Opencode => Box::new(OpencodeExecutor),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn config_path(&self) -> Option<std::path::PathBuf> {
|
||||||
|
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<Vec<&'static str>> {
|
||||||
|
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
|
/// Stream output from a child process to the database
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use std::sync::Arc;
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Extension,
|
extract::{Extension, Query},
|
||||||
response::Json as ResponseJson,
|
response::Json as ResponseJson,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::RwLock;
|
use serde_json::Value;
|
||||||
|
use tokio::{fs, sync::RwLock};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
executor::ExecutorConfig,
|
||||||
models::{
|
models::{
|
||||||
config::{Config, EditorConstants, SoundConstants},
|
config::{Config, EditorConstants, SoundConstants},
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -23,6 +25,8 @@ pub fn config_router() -> Router {
|
|||||||
.route("/config", get(get_config))
|
.route("/config", get(get_config))
|
||||||
.route("/config", post(update_config))
|
.route("/config", post(update_config))
|
||||||
.route("/config/constants", get(get_config_constants))
|
.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(
|
async fn get_config(
|
||||||
@@ -80,3 +84,278 @@ async fn get_config_constants() -> ResponseJson<ApiResponse<ConfigConstants>> {
|
|||||||
message: Some("Config constants retrieved successfully".to_string()),
|
message: Some("Config constants retrieved successfully".to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct McpServerQuery {
|
||||||
|
executor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common logic for resolving executor configuration and validating MCP support
|
||||||
|
fn resolve_executor_config(
|
||||||
|
query_executor: Option<String>,
|
||||||
|
saved_config: &ExecutorConfig,
|
||||||
|
) -> Result<ExecutorConfig, String> {
|
||||||
|
let executor_config = match query_executor {
|
||||||
|
Some(executor_type) => executor_type
|
||||||
|
.parse::<ExecutorConfig>()
|
||||||
|
.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<Arc<RwLock<Config>>>,
|
||||||
|
Query(query): Query<McpServerQuery>,
|
||||||
|
) -> ResponseJson<ApiResponse<Value>> {
|
||||||
|
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<Arc<RwLock<Config>>>,
|
||||||
|
Query(query): Query<McpServerQuery>,
|
||||||
|
Json(new_servers): Json<HashMap<String, Value>>,
|
||||||
|
) -> ResponseJson<ApiResponse<String>> {
|
||||||
|
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<String, Value>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// 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<HashMap<String, Value>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// 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<String, Value> {
|
||||||
|
// 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<String, Value>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// 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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Projects } from '@/pages/projects';
|
|||||||
import { ProjectTasks } from '@/pages/project-tasks';
|
import { ProjectTasks } from '@/pages/project-tasks';
|
||||||
import { TaskAttemptComparePage } from '@/pages/task-attempt-compare';
|
import { TaskAttemptComparePage } from '@/pages/task-attempt-compare';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { McpServers } from '@/pages/McpServers';
|
||||||
import { DisclaimerDialog } from '@/components/DisclaimerDialog';
|
import { DisclaimerDialog } from '@/components/DisclaimerDialog';
|
||||||
import { OnboardingDialog } from '@/components/OnboardingDialog';
|
import { OnboardingDialog } from '@/components/OnboardingDialog';
|
||||||
import { ConfigProvider, useConfig } from '@/components/config-provider';
|
import { ConfigProvider, useConfig } from '@/components/config-provider';
|
||||||
@@ -131,6 +132,7 @@ function AppContent() {
|
|||||||
element={<TaskAttemptComparePage />}
|
element={<TaskAttemptComparePage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/mcp-servers" element={<McpServers />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Logo } from '@/components/logo';
|
||||||
import { SupportDialog } from '@/components/support-dialog';
|
import { SupportDialog } from '@/components/support-dialog';
|
||||||
|
|
||||||
@@ -26,6 +26,18 @@ export function Navbar() {
|
|||||||
Projects
|
Projects
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={
|
||||||
|
location.pathname === '/mcp-servers' ? 'default' : 'ghost'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Link to="/mcp-servers">
|
||||||
|
<Server className="mr-2 h-4 w-4" />
|
||||||
|
MCP Servers
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant={
|
variant={
|
||||||
|
|||||||
357
frontend/src/pages/McpServers.tsx
Normal file
357
frontend/src/pages/McpServers.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { EXECUTOR_TYPES, EXECUTOR_LABELS } from 'shared/types';
|
||||||
|
import { useConfig } from '@/components/config-provider';
|
||||||
|
|
||||||
|
export function McpServers() {
|
||||||
|
const { config } = useConfig();
|
||||||
|
const [mcpServers, setMcpServers] = useState('{}');
|
||||||
|
const [mcpError, setMcpError] = useState<string | null>(null);
|
||||||
|
const [mcpLoading, setMcpLoading] = useState(true);
|
||||||
|
const [selectedMcpExecutor, setSelectedMcpExecutor] = useState<string>('');
|
||||||
|
const [mcpApplying, setMcpApplying] = useState(false);
|
||||||
|
const [mcpConfigPath, setMcpConfigPath] = useState<string>('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Initialize selected MCP executor when config loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.executor?.type && !selectedMcpExecutor) {
|
||||||
|
setSelectedMcpExecutor(config.executor.type);
|
||||||
|
}
|
||||||
|
}, [config?.executor?.type, selectedMcpExecutor]);
|
||||||
|
|
||||||
|
// Load existing MCP configuration when selected executor changes
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMcpServersForExecutor = async (executorType: string) => {
|
||||||
|
// Reset state when loading
|
||||||
|
setMcpLoading(true);
|
||||||
|
setMcpError(null);
|
||||||
|
|
||||||
|
// Set default empty config based on executor type
|
||||||
|
const defaultConfig =
|
||||||
|
executorType === 'amp'
|
||||||
|
? '{\n "amp.mcpServers": {\n }\n}'
|
||||||
|
: '{\n "mcpServers": {\n }\n}';
|
||||||
|
setMcpServers(defaultConfig);
|
||||||
|
setMcpConfigPath('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load MCP servers for the selected executor
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/mcp-servers?executor=${executorType}`
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
// Handle new response format with servers and config_path
|
||||||
|
const data = result.data || {};
|
||||||
|
const servers = data.servers || {};
|
||||||
|
const configPath = data.config_path || '';
|
||||||
|
|
||||||
|
// Create the full configuration structure based on executor type
|
||||||
|
let fullConfig;
|
||||||
|
if (executorType === 'amp') {
|
||||||
|
// For AMP, use the amp.mcpServers structure
|
||||||
|
fullConfig = { 'amp.mcpServers': servers };
|
||||||
|
} else {
|
||||||
|
// For other executors, use the standard mcpServers structure
|
||||||
|
fullConfig = { mcpServers: servers };
|
||||||
|
}
|
||||||
|
|
||||||
|
const configJson = JSON.stringify(fullConfig, null, 2);
|
||||||
|
setMcpServers(configJson);
|
||||||
|
setMcpConfigPath(configPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
if (
|
||||||
|
result.message &&
|
||||||
|
result.message.includes('does not support MCP')
|
||||||
|
) {
|
||||||
|
// This executor doesn't support MCP - show warning message
|
||||||
|
setMcpError(result.message);
|
||||||
|
} else {
|
||||||
|
console.warn('Failed to load MCP servers:', response.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading MCP servers:', err);
|
||||||
|
} finally {
|
||||||
|
setMcpLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load MCP servers for the selected MCP executor
|
||||||
|
if (selectedMcpExecutor) {
|
||||||
|
loadMcpServersForExecutor(selectedMcpExecutor);
|
||||||
|
}
|
||||||
|
}, [selectedMcpExecutor]);
|
||||||
|
|
||||||
|
const handleMcpServersChange = (value: string) => {
|
||||||
|
setMcpServers(value);
|
||||||
|
setMcpError(null);
|
||||||
|
|
||||||
|
// Validate JSON on change
|
||||||
|
if (value.trim()) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(value);
|
||||||
|
// Validate that the config has the expected structure based on executor type
|
||||||
|
if (selectedMcpExecutor === 'amp') {
|
||||||
|
if (
|
||||||
|
!config['amp.mcpServers'] ||
|
||||||
|
typeof config['amp.mcpServers'] !== 'object'
|
||||||
|
) {
|
||||||
|
setMcpError(
|
||||||
|
'AMP configuration must contain an "amp.mcpServers" object'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
||||||
|
setMcpError('Configuration must contain an "mcpServers" object');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMcpError('Invalid JSON format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyMcpServers = async () => {
|
||||||
|
if (!selectedMcpExecutor) return;
|
||||||
|
|
||||||
|
setMcpApplying(true);
|
||||||
|
setMcpError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate and save MCP configuration
|
||||||
|
if (mcpServers.trim()) {
|
||||||
|
try {
|
||||||
|
const fullConfig = JSON.parse(mcpServers);
|
||||||
|
|
||||||
|
// Validate that the config has the expected structure based on executor type
|
||||||
|
let mcpServersConfig;
|
||||||
|
if (selectedMcpExecutor === 'amp') {
|
||||||
|
if (
|
||||||
|
!fullConfig['amp.mcpServers'] ||
|
||||||
|
typeof fullConfig['amp.mcpServers'] !== 'object'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'AMP configuration must contain an "amp.mcpServers" object'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Extract just the inner servers object for the API - backend will handle nesting
|
||||||
|
mcpServersConfig = fullConfig['amp.mcpServers'];
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
!fullConfig.mcpServers ||
|
||||||
|
typeof fullConfig.mcpServers !== 'object'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Configuration must contain an "mcpServers" object'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Extract just the mcpServers part for the API
|
||||||
|
mcpServersConfig = fullConfig.mcpServers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpResponse = await fetch(
|
||||||
|
`/api/mcp-servers?executor=${selectedMcpExecutor}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(mcpServersConfig),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mcpResponse.ok) {
|
||||||
|
const errorData = await mcpResponse.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to save MCP servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
} catch (mcpErr) {
|
||||||
|
if (mcpErr instanceof SyntaxError) {
|
||||||
|
setMcpError('Invalid JSON format');
|
||||||
|
} else {
|
||||||
|
setMcpError(
|
||||||
|
mcpErr instanceof Error
|
||||||
|
? mcpErr.message
|
||||||
|
: 'Failed to save MCP servers'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setMcpError('Failed to apply MCP server configuration');
|
||||||
|
console.error('Error applying MCP servers:', err);
|
||||||
|
} finally {
|
||||||
|
setMcpApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>Failed to load configuration.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">MCP Servers</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Configure MCP servers to extend executor capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mcpError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
MCP Configuration Error: {mcpError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
|
||||||
|
<AlertDescription className="font-medium">
|
||||||
|
✓ MCP configuration saved successfully!
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure MCP servers for different executors to extend their
|
||||||
|
capabilities with custom tools and resources.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mcp-executor">Executor</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedMcpExecutor}
|
||||||
|
onValueChange={(value: string) => setSelectedMcpExecutor(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="mcp-executor">
|
||||||
|
<SelectValue placeholder="Select executor" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXECUTOR_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{EXECUTOR_LABELS[type]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose which executor to configure MCP servers for.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mcpError && mcpError.includes('does not support MCP') ? (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
MCP Not Supported
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
<p>{mcpError}</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
To use MCP servers, please select a different executor
|
||||||
|
(Claude, Amp, or Gemini) above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mcp-servers">MCP Server Configuration</Label>
|
||||||
|
<Textarea
|
||||||
|
id="mcp-servers"
|
||||||
|
placeholder={
|
||||||
|
mcpLoading
|
||||||
|
? 'Loading current configuration...'
|
||||||
|
: '{\n "server-name": {\n "type": "stdio",\n "command": "your-command",\n "args": ["arg1", "arg2"]\n }\n}'
|
||||||
|
}
|
||||||
|
value={mcpLoading ? 'Loading...' : mcpServers}
|
||||||
|
onChange={(e) => handleMcpServersChange(e.target.value)}
|
||||||
|
disabled={mcpLoading}
|
||||||
|
className="font-mono text-sm min-h-[300px]"
|
||||||
|
/>
|
||||||
|
{mcpError && !mcpError.includes('does not support MCP') && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{mcpError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{mcpLoading ? (
|
||||||
|
'Loading current MCP server configuration...'
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Changes will be saved to:
|
||||||
|
{mcpConfigPath && (
|
||||||
|
<span className="ml-2 font-mono text-xs">
|
||||||
|
{mcpConfigPath}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sticky save button */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-background/80 backdrop-blur-sm border-t p-4 z-10">
|
||||||
|
<div className="container mx-auto max-w-4xl flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyMcpServers}
|
||||||
|
disabled={mcpApplying || mcpLoading || !!mcpError || success}
|
||||||
|
className={success ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||||
|
>
|
||||||
|
{mcpApplying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{success && <span className="mr-2">✓</span>}
|
||||||
|
{success ? 'Settings Saved!' : 'Save Settings'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer to prevent content from being hidden behind sticky button */}
|
||||||
|
<div className="h-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ export function Settings() {
|
|||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Save the main configuration
|
||||||
const success = await saveConfig();
|
const success = await saveConfig();
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|||||||
Reference in New Issue
Block a user