use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "transport", rename_all = "lowercase")]
pub enum McpTransport {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
},
Http {
url: String,
#[serde(default)]
headers: HashMap<String, String>,
},
}
fn default_true() -> bool {
true
}
fn default_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(default)]
pub slot: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(flatten)]
pub transport: McpTransport,
#[serde(default)]
pub requires_confirmation: bool,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct McpServersFile {
#[serde(default, rename = "mcp_servers")]
servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone)]
pub struct McpConfigManager {
config_path: PathBuf,
}
impl McpConfigManager {
pub fn new(config_path: impl AsRef<Path>) -> Self {
Self {
config_path: config_path.as_ref().to_path_buf(),
}
}
fn bootstrap_from_example(&self) {
let example_path = self.config_path.with_file_name("mcp_servers.example.toml");
if example_path.exists() {
if let Err(err) = std::fs::copy(&example_path, &self.config_path) {
tracing::warn!(
"mcp: failed to bootstrap {} from {}: {err}",
self.config_path.display(),
example_path.display()
);
}
}
}
pub fn load(&self) -> Result<Vec<McpServerConfig>, String> {
if !self.config_path.exists() {
self.bootstrap_from_example();
}
if !self.config_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&self.config_path)
.map_err(|err| format!("failed to read {}: {err}", self.config_path.display()))?;
let file: McpServersFile = toml::from_str(&content)
.map_err(|err| format!("failed to parse {}: {err}", self.config_path.display()))?;
for server in &file.servers {
validate_server(server)?;
}
Ok(file.servers)
}
pub fn save(&self, servers: &[McpServerConfig]) -> Result<(), String> {
for server in servers {
validate_server(server)?;
}
let file = McpServersFile {
servers: servers.to_vec(),
};
let output = toml::to_string_pretty(&file)
.map_err(|err| format!("failed to serialize mcp_servers.toml: {err}"))?;
std::fs::write(&self.config_path, output)
.map_err(|err| format!("failed to write {}: {err}", self.config_path.display()))?;
Ok(())
}
pub fn set_enabled(&self, name: &str, enabled: bool) -> Result<(), String> {
let mut servers = self.load()?;
let server = servers
.iter_mut()
.find(|s| s.name == name)
.ok_or_else(|| format!("no MCP server named '{name}'"))?;
server.enabled = enabled;
self.save(&servers)
}
pub fn add(&self, server: McpServerConfig) -> Result<(), String> {
let mut servers = self.load()?;
if servers.iter().any(|s| s.name == server.name) {
return Err(format!(
"an MCP server named '{}' already exists",
server.name
));
}
servers.push(server);
self.save(&servers)
}
pub fn update(&self, name: &str, server: McpServerConfig) -> Result<(), String> {
let mut servers = self.load()?;
let idx = servers
.iter()
.position(|s| s.name == name)
.ok_or_else(|| format!("no MCP server named '{name}'"))?;
if server.name != name && servers.iter().any(|s| s.name == server.name) {
return Err(format!(
"an MCP server named '{}' already exists",
server.name
));
}
servers[idx] = server;
self.save(&servers)
}
pub fn remove(&self, name: &str) -> Result<(), String> {
let mut servers = self.load()?;
let before = servers.len();
servers.retain(|s| s.name != name);
if servers.len() == before {
return Err(format!("no MCP server named '{name}'"));
}
self.save(&servers)
}
}
fn validate_server(server: &McpServerConfig) -> Result<(), String> {
match &server.transport {
McpTransport::Stdio { env, .. } => {
for (key, value) in env {
if looks_like_literal_secret(value) {
return Err(format!(
"mcp server '{}': env var '{}' looks like a literal secret; use \"${{{}}}\" to reference a host environment variable instead",
server.name, key, key
));
}
}
}
McpTransport::Http { headers, .. } => {
for (key, value) in headers {
if looks_like_literal_secret(value) {
return Err(format!(
"mcp server '{}': header '{}' looks like a literal secret; use \"${{{}}}\" to reference a host environment variable instead",
server.name, key, key
));
}
}
}
}
Ok(())
}
fn looks_like_literal_secret(value: &str) -> bool {
if value.starts_with("${") && value.ends_with('}') {
return false;
}
if value.starts_with("http://")
|| value.starts_with("https://")
|| value.contains(['/', ':', ' ', '\\'])
{
return false;
}
value.len() >= 20
&& value.chars().any(|c| c.is_ascii_digit())
&& value.chars().any(|c| c.is_ascii_alphabetic())
}
pub fn resolve_env_value(raw: &str) -> String {
if let Some(var_name) = raw.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
std::env::var(var_name).unwrap_or_default()
} else {
raw.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_config_path() -> PathBuf {
let suffix = format!(
"ghostlink-mcp-{}-{}.toml",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
std::env::temp_dir().join(suffix)
}
#[test]
fn missing_file_yields_empty_list() {
let manager = McpConfigManager::new(temp_config_path());
assert!(manager.load().unwrap().is_empty());
}
#[test]
fn missing_config_bootstraps_from_sibling_example() {
let dir = std::env::temp_dir().join(format!(
"ghostlink-mcp-bootstrap-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let example_path = dir.join("mcp_servers.example.toml");
std::fs::write(
&example_path,
r#"[[mcp_servers]]
name = "filesystem"
slot = "file_operations"
enabled = true
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "./mcp_workspace"]
"#,
)
.unwrap();
let config_path = dir.join("mcp_servers.toml");
assert!(!config_path.exists());
let manager = McpConfigManager::new(&config_path);
let servers = manager.load().unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "filesystem");
assert!(
config_path.exists(),
"load() should have copied the example into place"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_and_load_round_trip() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
let servers = vec![McpServerConfig {
name: "filesystem".to_string(),
slot: "file_operations".to_string(),
enabled: true,
transport: McpTransport::Stdio {
command: "npx".to_string(),
args: vec![
"-y".to_string(),
"@modelcontextprotocol/server-filesystem".to_string(),
],
env: HashMap::new(),
},
requires_confirmation: false,
timeout_secs: 30,
}];
manager.save(&servers).unwrap();
let loaded = manager.load().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "filesystem");
assert!(loaded[0].enabled);
manager.set_enabled("filesystem", false).unwrap();
let loaded = manager.load().unwrap();
assert!(!loaded[0].enabled);
let _ = std::fs::remove_file(path);
}
#[test]
fn literal_secret_is_rejected() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
let servers = vec![McpServerConfig {
name: "brave-search".to_string(),
slot: "web_search".to_string(),
enabled: false,
transport: McpTransport::Stdio {
command: "npx".to_string(),
args: vec![],
env: HashMap::from([(
"BRAVE_API_KEY".to_string(),
"BSAxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1234".to_string(),
)]),
},
requires_confirmation: false,
timeout_secs: 30,
}];
assert!(manager.save(&servers).is_err());
let _ = std::fs::remove_file(path);
}
#[test]
fn literal_secret_in_http_header_is_rejected() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
let servers = vec![McpServerConfig {
name: "remote-tools".to_string(),
slot: "".to_string(),
enabled: false,
transport: McpTransport::Http {
url: "https://mcp.example.com/mcp".to_string(),
headers: HashMap::from([(
"X-Api-Key".to_string(),
"sk-liveSecretToken1234567890".to_string(),
)]),
},
requires_confirmation: false,
timeout_secs: 30,
}];
assert!(manager.save(&servers).is_err());
let _ = std::fs::remove_file(path);
}
#[test]
fn env_reference_is_accepted() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
let servers = vec![McpServerConfig {
name: "brave-search".to_string(),
slot: "web_search".to_string(),
enabled: false,
transport: McpTransport::Stdio {
command: "npx".to_string(),
args: vec![],
env: HashMap::from([("BRAVE_API_KEY".to_string(), "${BRAVE_API_KEY}".to_string())]),
},
requires_confirmation: false,
timeout_secs: 30,
}];
assert!(manager.save(&servers).is_ok());
let _ = std::fs::remove_file(path);
}
#[test]
fn env_reference_in_http_header_is_accepted() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
let servers = vec![McpServerConfig {
name: "remote-tools".to_string(),
slot: "".to_string(),
enabled: false,
transport: McpTransport::Http {
url: "https://mcp.example.com/mcp".to_string(),
headers: HashMap::from([(
"X-Api-Key".to_string(),
"${REMOTE_TOOLS_API_KEY}".to_string(),
)]),
},
requires_confirmation: false,
timeout_secs: 30,
}];
assert!(manager.save(&servers).is_ok());
let _ = std::fs::remove_file(path);
}
#[test]
fn url_shaped_value_is_not_flagged_as_a_secret() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
let servers = vec![McpServerConfig {
name: "vision".to_string(),
slot: "".to_string(),
enabled: false,
transport: McpTransport::Stdio {
command: "mcp-vision".to_string(),
args: vec![],
env: HashMap::from([(
"OLLAMA_URL".to_string(),
"http://127.0.0.1:11434".to_string(),
)]),
},
requires_confirmation: false,
timeout_secs: 30,
}];
assert!(manager.save(&servers).is_ok());
let _ = std::fs::remove_file(path);
}
fn stdio_server(name: &str) -> McpServerConfig {
McpServerConfig {
name: name.to_string(),
slot: "".to_string(),
enabled: true,
transport: McpTransport::Stdio {
command: "npx".to_string(),
args: vec![],
env: HashMap::new(),
},
requires_confirmation: false,
timeout_secs: 30,
}
}
#[test]
fn add_appends_a_new_server() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
manager.add(stdio_server("filesystem")).unwrap();
let loaded = manager.load().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "filesystem");
let _ = std::fs::remove_file(path);
}
#[test]
fn add_rejects_duplicate_name() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
manager.add(stdio_server("filesystem")).unwrap();
assert!(manager.add(stdio_server("filesystem")).is_err());
let _ = std::fs::remove_file(path);
}
#[test]
fn update_replaces_existing_server_and_allows_rename() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
manager.add(stdio_server("filesystem")).unwrap();
let mut renamed = stdio_server("filesystem-v2");
renamed.requires_confirmation = true;
manager.update("filesystem", renamed).unwrap();
let loaded = manager.load().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "filesystem-v2");
assert!(loaded[0].requires_confirmation);
let _ = std::fs::remove_file(path);
}
#[test]
fn update_missing_server_fails() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
assert!(manager.update("nope", stdio_server("nope")).is_err());
let _ = std::fs::remove_file(path);
}
#[test]
fn remove_deletes_the_named_server() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
manager.add(stdio_server("filesystem")).unwrap();
manager.add(stdio_server("calculator")).unwrap();
manager.remove("filesystem").unwrap();
let loaded = manager.load().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "calculator");
let _ = std::fs::remove_file(path);
}
#[test]
fn remove_missing_server_fails() {
let path = temp_config_path();
let manager = McpConfigManager::new(&path);
assert!(manager.remove("nope").is_err());
let _ = std::fs::remove_file(path);
}
#[test]
fn resolve_env_value_reads_process_env() {
std::env::set_var("GHOSTLINK_MCP_TEST_VAR", "resolved-value");
assert_eq!(
resolve_env_value("${GHOSTLINK_MCP_TEST_VAR}"),
"resolved-value"
);
assert_eq!(resolve_env_value("literal"), "literal");
std::env::remove_var("GHOSTLINK_MCP_TEST_VAR");
}
}