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 serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
@@ -247,6 +249,21 @@ pub struct ExecutorConstants {
|
||||
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 {
|
||||
pub fn create_executor(&self) -> Box<dyn Executor> {
|
||||
match self {
|
||||
@@ -257,6 +274,47 @@ impl ExecutorConfig {
|
||||
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
|
||||
|
||||
@@ -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<ApiResponse<ConfigConstants>> {
|
||||
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 { 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={<TaskAttemptComparePage />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/mcp-servers" element={<McpServers />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
</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
|
||||
asChild
|
||||
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);
|
||||
|
||||
try {
|
||||
// Save the main configuration
|
||||
const success = await saveConfig();
|
||||
|
||||
if (success) {
|
||||
|
||||
Reference in New Issue
Block a user