//! Utilities for reading and writing external agent config files (not the server's own config). //! //! These helpers abstract over JSON vs TOML formats used by different agents. use std::{collections::HashMap, sync::LazyLock}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use tokio::fs; use ts_rs::TS; use crate::executors::{CodingAgent, ExecutorError}; static DEFAULT_MCP_JSON: &str = include_str!("../default_mcp.json"); pub static PRECONFIGURED_MCP_SERVERS: LazyLock = LazyLock::new(|| { serde_json::from_str::(DEFAULT_MCP_JSON).expect("Failed to parse default MCP JSON") }); #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct McpConfig { servers: HashMap, pub servers_path: Vec, pub template: serde_json::Value, pub preconfigured: serde_json::Value, pub is_toml_config: bool, } impl McpConfig { pub fn new( servers_path: Vec, template: serde_json::Value, preconfigured: serde_json::Value, is_toml_config: bool, ) -> Self { Self { servers: HashMap::new(), servers_path, template, preconfigured, is_toml_config, } } pub fn set_servers(&mut self, servers: HashMap) { self.servers = servers; } } /// Read an agent's external config file (JSON or TOML) and normalize it to serde_json::Value. pub async fn read_agent_config( config_path: &std::path::Path, mcp_config: &McpConfig, ) -> Result { if let Ok(file_content) = fs::read_to_string(config_path).await { if mcp_config.is_toml_config { // Parse TOML then convert to JSON Value if file_content.trim().is_empty() { return Ok(serde_json::json!({})); } let toml_val: toml::Value = toml::from_str(&file_content)?; let json_string = serde_json::to_string(&toml_val)?; Ok(serde_json::from_str(&json_string)?) } else { Ok(serde_json::from_str(&file_content)?) } } else { Ok(mcp_config.template.clone()) } } /// Write an agent's external config (as serde_json::Value) back to disk in the agent's format (JSON or TOML). pub async fn write_agent_config( config_path: &std::path::Path, mcp_config: &McpConfig, config: &Value, ) -> Result<(), ExecutorError> { if mcp_config.is_toml_config { // Convert JSON Value back to TOML let toml_value: toml::Value = serde_json::from_str(&serde_json::to_string(config)?)?; let toml_content = toml::to_string_pretty(&toml_value)?; fs::write(config_path, toml_content).await?; } else { let json_content = serde_json::to_string_pretty(config)?; fs::write(config_path, json_content).await?; } Ok(()) } type ServerMap = Map; fn is_http_server(s: &Map) -> bool { matches!(s.get("type").and_then(Value::as_str), Some("http")) } fn is_stdio(s: &Map) -> bool { !is_http_server(s) && s.get("command").is_some() } fn extract_meta(mut obj: ServerMap) -> (ServerMap, Option) { let meta = obj.remove("meta"); (obj, meta) } fn attach_meta(mut obj: ServerMap, meta: Option) -> Value { if let Some(m) = meta { obj.insert("meta".to_string(), m); } Value::Object(obj) } fn ensure_header(headers: &mut Map, key: &str, val: &str) { match headers.get_mut(key) { Some(Value::String(_)) => {} _ => { headers.insert(key.to_string(), Value::String(val.to_string())); } } } fn transform_http_servers(mut servers: ServerMap, mut f: F) -> ServerMap where F: FnMut(Map) -> Map, { for (_k, v) in servers.iter_mut() { if let Value::Object(s) = v && is_http_server(s) { let taken = std::mem::take(s); *s = f(taken); } } servers } // --- Adapters --------------------------------------------------------------- fn adapt_passthrough(servers: ServerMap, meta: Option) -> Value { attach_meta(servers, meta) } fn adapt_gemini(servers: ServerMap, meta: Option) -> Value { let servers = transform_http_servers(servers, |mut s| { let url = s .remove("url") .unwrap_or_else(|| Value::String(String::new())); let mut headers = s .remove("headers") .and_then(|v| v.as_object().cloned()) .unwrap_or_default(); ensure_header( &mut headers, "Accept", "application/json, text/event-stream", ); Map::from_iter([ ("httpUrl".to_string(), url), ("headers".to_string(), Value::Object(headers)), ]) }); attach_meta(servers, meta) } fn adapt_cursor(servers: ServerMap, meta: Option) -> Value { let servers = transform_http_servers(servers, |mut s| { let url = s .remove("url") .unwrap_or_else(|| Value::String(String::new())); let headers = s .remove("headers") .unwrap_or_else(|| Value::Object(Default::default())); Map::from_iter([("url".to_string(), url), ("headers".to_string(), headers)]) }); attach_meta(servers, meta) } fn adapt_codex(mut servers: ServerMap, mut meta: Option) -> Value { servers.retain(|_, v| v.as_object().map(is_stdio).unwrap_or(false)); if let Some(Value::Object(ref mut m)) = meta { m.retain(|k, _| servers.contains_key(k)); servers.insert("meta".to_string(), Value::Object(std::mem::take(m))); meta = None; // already attached above } attach_meta(servers, meta) } fn adapt_opencode(servers: ServerMap, meta: Option) -> Value { let mut servers = transform_http_servers(servers, |mut s| { let url = s .remove("url") .unwrap_or_else(|| Value::String(String::new())); let mut headers = s .remove("headers") .and_then(|v| v.as_object().cloned()) .unwrap_or_default(); ensure_header( &mut headers, "Accept", "application/json, text/event-stream", ); Map::from_iter([ ("type".to_string(), Value::String("remote".to_string())), ("url".to_string(), url), ("headers".to_string(), Value::Object(headers)), ("enabled".to_string(), Value::Bool(true)), ]) }); for (_k, v) in servers.iter_mut() { if let Value::Object(s) = v && is_stdio(s) { let command_str = s .remove("command") .and_then(|v| match v { Value::String(s) => Some(s), _ => None, }) .unwrap_or_default(); let mut cmd_vec: Vec = Vec::new(); if !command_str.is_empty() { cmd_vec.push(Value::String(command_str)); } if let Some(arr) = s.remove("args").and_then(|v| match v { Value::Array(arr) => Some(arr), _ => None, }) { for a in arr { match a { Value::String(s) => cmd_vec.push(Value::String(s)), other => cmd_vec.push(other), // fall back to raw value if not string } } } let mut new_map = Map::new(); new_map.insert("type".to_string(), Value::String("local".to_string())); new_map.insert("command".to_string(), Value::Array(cmd_vec)); new_map.insert("enabled".to_string(), Value::Bool(true)); *s = new_map; } } attach_meta(servers, meta) } fn adapt_copilot(mut servers: ServerMap, meta: Option) -> Value { for (_, value) in servers.iter_mut() { if let Value::Object(s) = value && !s.contains_key("tools") { s.insert( "tools".to_string(), Value::Array(vec![Value::String("*".to_string())]), ); } } attach_meta(servers, meta) } enum Adapter { Passthrough, Gemini, Cursor, Codex, Opencode, Copilot, } fn apply_adapter(adapter: Adapter, canonical: Value) -> Value { let (servers_only, meta) = match canonical.as_object() { Some(map) => extract_meta(map.clone()), None => (ServerMap::new(), None), }; match adapter { Adapter::Passthrough => adapt_passthrough(servers_only, meta), Adapter::Gemini => adapt_gemini(servers_only, meta), Adapter::Cursor => adapt_cursor(servers_only, meta), Adapter::Codex => adapt_codex(servers_only, meta), Adapter::Opencode => adapt_opencode(servers_only, meta), Adapter::Copilot => adapt_copilot(servers_only, meta), } } impl CodingAgent { pub fn preconfigured_mcp(&self) -> Value { use Adapter::*; let adapter = match self { CodingAgent::ClaudeCode(_) | CodingAgent::Amp(_) => Passthrough, CodingAgent::QwenCode(_) | CodingAgent::Gemini(_) => Gemini, CodingAgent::Cursor(_) => Cursor, CodingAgent::Codex(_) => Codex, CodingAgent::Opencode(_) => Opencode, CodingAgent::Copilot(..) => Copilot, }; let canonical = PRECONFIGURED_MCP_SERVERS.clone(); apply_adapter(adapter, canonical) } }