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:
Gabriel Gordon-Hall
2025-07-01 13:47:35 +01:00
committed by GitHub
parent 7620ba60fa
commit a1c97f787e
6 changed files with 713 additions and 4 deletions

View File

@@ -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

View File

@@ -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(())
}

View File

@@ -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>

View File

@@ -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={

View 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>
);
}

View File

@@ -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) {