2025-08-14 17:33:33 +01:00
|
|
|
//! 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.
|
|
|
|
|
|
2025-09-10 10:39:45 +01:00
|
|
|
use std::{collections::HashMap, sync::LazyLock};
|
2025-08-14 17:33:33 +01:00
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2025-09-10 10:39:45 +01:00
|
|
|
use serde_json::{Map, Value};
|
2025-08-14 17:33:33 +01:00
|
|
|
use tokio::fs;
|
|
|
|
|
use ts_rs::TS;
|
|
|
|
|
|
2025-09-10 10:39:45 +01:00
|
|
|
use crate::executors::{CodingAgent, ExecutorError};
|
|
|
|
|
|
|
|
|
|
static DEFAULT_MCP_JSON: &str = include_str!("../default_mcp.json");
|
|
|
|
|
pub static PRECONFIGURED_MCP_SERVERS: LazyLock<Value> = LazyLock::new(|| {
|
|
|
|
|
serde_json::from_str::<Value>(DEFAULT_MCP_JSON).expect("Failed to parse default MCP JSON")
|
|
|
|
|
});
|
2025-08-14 17:33:33 +01:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
|
|
|
pub struct McpConfig {
|
|
|
|
|
servers: HashMap<String, serde_json::Value>,
|
|
|
|
|
pub servers_path: Vec<String>,
|
|
|
|
|
pub template: serde_json::Value,
|
2025-09-10 10:39:45 +01:00
|
|
|
pub preconfigured: serde_json::Value,
|
2025-08-14 17:33:33 +01:00
|
|
|
pub is_toml_config: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl McpConfig {
|
|
|
|
|
pub fn new(
|
|
|
|
|
servers_path: Vec<String>,
|
|
|
|
|
template: serde_json::Value,
|
2025-09-10 10:39:45 +01:00
|
|
|
preconfigured: serde_json::Value,
|
2025-08-14 17:33:33 +01:00
|
|
|
is_toml_config: bool,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
servers: HashMap::new(),
|
|
|
|
|
servers_path,
|
|
|
|
|
template,
|
2025-09-10 10:39:45 +01:00
|
|
|
preconfigured,
|
2025-08-14 17:33:33 +01:00
|
|
|
is_toml_config,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pub fn set_servers(&mut self, servers: HashMap<String, serde_json::Value>) {
|
|
|
|
|
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<Value, ExecutorError> {
|
|
|
|
|
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(())
|
|
|
|
|
}
|
2025-09-10 10:39:45 +01:00
|
|
|
|
|
|
|
|
type ServerMap = Map<String, Value>;
|
|
|
|
|
|
|
|
|
|
fn is_http_server(s: &Map<String, Value>) -> bool {
|
|
|
|
|
matches!(s.get("type").and_then(Value::as_str), Some("http"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_stdio(s: &Map<String, Value>) -> bool {
|
|
|
|
|
!is_http_server(s) && s.get("command").is_some()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extract_meta(mut obj: ServerMap) -> (ServerMap, Option<Value>) {
|
|
|
|
|
let meta = obj.remove("meta");
|
|
|
|
|
(obj, meta)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn attach_meta(mut obj: ServerMap, meta: Option<Value>) -> Value {
|
|
|
|
|
if let Some(m) = meta {
|
|
|
|
|
obj.insert("meta".to_string(), m);
|
|
|
|
|
}
|
|
|
|
|
Value::Object(obj)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_header(headers: &mut Map<String, Value>, 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<F>(mut servers: ServerMap, mut f: F) -> ServerMap
|
|
|
|
|
where
|
|
|
|
|
F: FnMut(Map<String, Value>) -> Map<String, Value>,
|
|
|
|
|
{
|
|
|
|
|
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>) -> Value {
|
|
|
|
|
attach_meta(servers, meta)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn adapt_gemini(servers: ServerMap, meta: Option<Value>) -> 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>) -> 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>) -> 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>) -> 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<Value> = 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-03 13:12:53 +01:00
|
|
|
fn adapt_copilot(mut servers: ServerMap, meta: Option<Value>) -> 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-10 10:39:45 +01:00
|
|
|
enum Adapter {
|
|
|
|
|
Passthrough,
|
|
|
|
|
Gemini,
|
|
|
|
|
Cursor,
|
|
|
|
|
Codex,
|
|
|
|
|
Opencode,
|
2025-10-03 13:12:53 +01:00
|
|
|
Copilot,
|
2025-09-10 10:39:45 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
2025-10-03 13:12:53 +01:00
|
|
|
Adapter::Copilot => adapt_copilot(servers_only, meta),
|
2025-09-10 10:39:45 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2025-10-03 13:12:53 +01:00
|
|
|
CodingAgent::Copilot(..) => Copilot,
|
2025-09-10 10:39:45 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let canonical = PRECONFIGURED_MCP_SERVERS.clone();
|
|
|
|
|
apply_adapter(adapter, canonical)
|
|
|
|
|
}
|
|
|
|
|
}
|