use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tracing::{debug, warn};
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MCPConfig {
pub servers: Option<Vec<MCPServerConfig>>,
pub options: Option<MCPGlobalOptions>,
pub auth_headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorMCPConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: Option<HashMap<String, CursorMCPServerConfig>>,
pub settings: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorMCPServerConfig {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub cwd: Option<String>,
pub env: Option<HashMap<String, String>>,
pub transport: Option<CursorTransportConfig>,
pub description: Option<String>,
pub tools: Option<Vec<String>>,
pub url: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorTransportConfig {
#[serde(rename = "type")]
pub transport_type: Option<String>,
pub host: Option<String>,
pub port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeDesktopConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: Option<HashMap<String, ClaudeDesktopServerConfig>>,
#[serde(rename = "globalShortcut")]
pub global_shortcut: Option<String>,
#[serde(flatten)]
pub other_settings: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeDesktopServerConfig {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub cwd: Option<String>,
pub disabled: Option<bool>,
pub url: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeSettings {
#[serde(rename = "mcp.servers")]
pub mcp_servers: Option<HashMap<String, VSCodeMCPServerConfig>>,
#[serde(flatten)]
pub other_settings: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeMCPServerConfig {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub cwd: Option<String>,
pub url: Option<String>,
#[serde(rename = "type")]
pub transport_type: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeMCPConfig {
pub servers: Option<HashMap<String, VSCodeMCPServerConfig>>,
pub inputs: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeObjectMCPConfig {
pub servers: Option<HashMap<String, VSCodeServerConfig>>,
pub inputs: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPServerConfig {
pub name: Option<String>,
pub url: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub description: Option<String>,
pub auth_headers: Option<HashMap<String, String>>,
pub options: Option<MCPServerOptions>,
}
impl MCPServerConfig {
pub fn to_display_url(&self) -> String {
if let Some(url) = &self.url {
url.clone()
} else if let Some(command) = &self.command {
if let Some(name) = &self.name {
format!("stdio:{command}[{name}]")
} else if let Some(args) = &self.args {
if let Some(main_arg) = args.first() {
format!("stdio:{command}[{main_arg}]")
} else {
format!("stdio:{command}[unknown]")
}
} else {
format!("stdio:{command}")
}
} else {
"unknown".to_string()
}
}
pub fn scan_url(&self) -> Option<&str> {
self.url.as_deref()
}
pub fn dedup_key(&self) -> String {
if let Some(url) = &self.url {
format!("http:{}", MCPConfigManager::normalize_url(url))
} else {
let name = self.name.as_deref().unwrap_or("unnamed");
let command = self.command.as_deref().unwrap_or("unknown");
let args = if let Some(args) = &self.args {
args.join(" ")
} else {
String::new()
};
format!("stdio:{name}:{command}:{args}")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPGlobalOptions {
pub timeout: Option<u64>,
pub http_timeout: Option<u64>,
pub format: Option<String>,
pub detailed: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPServerOptions {
pub timeout: Option<u64>,
pub http_timeout: Option<u64>,
pub format: Option<String>,
pub detailed: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeArrayMCPConfig {
pub servers: Option<Vec<VSCodeArrayServerConfig>>,
pub inputs: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeArrayServerConfig {
pub name: Option<String>,
pub url: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
#[serde(rename = "type")]
pub server_type: Option<String>,
pub description: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VSCodeServerConfig {
#[serde(rename = "type")]
pub server_type: Option<String>,
pub url: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub gallery: Option<bool>,
pub description: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CursorServerConfig {
pub command: String,
pub args: Vec<String>,
pub env: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindsurfMCPConfig {
pub servers: Option<HashMap<String, WindsurfServerConfig>>,
pub global: Option<MCPGlobalOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindsurfServerConfig {
pub url: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
#[serde(rename = "type")]
pub server_type: Option<String>,
pub description: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeMCPConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: Option<HashMap<String, ClaudeServerConfig>>,
pub servers: Option<HashMap<String, ClaudeServerConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeServerConfig {
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub url: Option<String>,
#[serde(rename = "type")]
pub server_type: Option<String>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodeConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: Option<HashMap<String, ClaudeCodeServerConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeCodeServerConfig {
#[serde(rename = "type")]
pub server_type: String,
pub command: String,
pub args: Vec<String>,
pub env: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZedMCPConfig {
pub context_servers: Option<Vec<HashMap<String, ZedServerConfig>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZedServerConfig {
pub command: Option<ZedCommandConfig>,
pub url: Option<String>,
pub env: Option<HashMap<String, String>>,
pub headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZedCommandConfig {
pub path: String,
pub args: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZencoderMCPConfig {
pub command: String,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
}
impl From<VSCodeMCPConfig> for MCPConfig {
fn from(vscode_config: VSCodeMCPConfig) -> Self {
let servers = vscode_config.servers.map(|servers_map| {
servers_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: server_config.url,
command: server_config.command,
args: server_config.args,
env: server_config.env,
description: None, auth_headers: server_config.headers,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<VSCodeObjectMCPConfig> for MCPConfig {
fn from(vscode_config: VSCodeObjectMCPConfig) -> Self {
let servers = vscode_config.servers.map(|servers_map| {
servers_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: server_config.url,
command: server_config.command,
args: server_config.args,
env: server_config.env,
description: server_config.description, auth_headers: server_config.headers,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<VSCodeArrayMCPConfig> for MCPConfig {
fn from(vscode_config: VSCodeArrayMCPConfig) -> Self {
let servers = vscode_config.servers.map(|servers_vec| {
servers_vec
.into_iter()
.map(|server_config| MCPServerConfig {
name: server_config.name,
url: server_config.url,
command: server_config.command,
args: server_config.args,
env: server_config.env,
description: server_config.description,
auth_headers: server_config.headers,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<CursorMCPConfig> for MCPConfig {
fn from(cursor_config: CursorMCPConfig) -> Self {
let servers = cursor_config.mcp_servers.map(|servers_map| {
servers_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: server_config.url,
command: server_config.command,
args: server_config.args,
env: server_config.env,
description: server_config.description,
auth_headers: server_config.headers,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<WindsurfMCPConfig> for MCPConfig {
fn from(windsurf_config: WindsurfMCPConfig) -> Self {
let servers = windsurf_config.servers.map(|servers_map| {
servers_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: server_config.url,
command: server_config.command,
args: server_config.args,
env: server_config.env,
description: server_config.description,
auth_headers: server_config.headers,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: windsurf_config.global,
auth_headers: None,
}
}
}
impl From<ClaudeMCPConfig> for MCPConfig {
fn from(claude_config: ClaudeMCPConfig) -> Self {
let servers_map = claude_config.mcp_servers.or(claude_config.servers);
let servers = servers_map.map(|servers_map| {
servers_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: server_config.url,
command: server_config.command,
args: server_config.args,
env: server_config.env,
description: None,
auth_headers: server_config.headers,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<ClaudeCodeConfig> for MCPConfig {
fn from(claude_code_config: ClaudeCodeConfig) -> Self {
let servers = claude_code_config.mcp_servers.map(|servers_map| {
servers_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: None,
command: Some(server_config.command),
args: Some(server_config.args),
env: server_config.env,
description: None,
auth_headers: None,
options: None,
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<ZedMCPConfig> for MCPConfig {
fn from(zed_config: ZedMCPConfig) -> Self {
let servers = zed_config.context_servers.map(|context_servers| {
context_servers
.into_iter()
.flat_map(|server_map| {
server_map
.into_iter()
.map(|(name, server_config)| MCPServerConfig {
name: Some(name),
url: server_config.url,
command: server_config.command.as_ref().map(|cmd| cmd.path.clone()),
args: server_config
.command
.as_ref()
.and_then(|cmd| cmd.args.clone()),
env: server_config.env,
description: server_config.command.as_ref().map(|cmd| {
format!(
"Command: {} {}",
cmd.path,
cmd.args
.as_ref()
.map(|args| args.join(" "))
.unwrap_or_default()
)
}),
auth_headers: server_config.headers,
options: None,
})
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
impl From<ZencoderMCPConfig> for MCPConfig {
fn from(zencoder_config: ZencoderMCPConfig) -> Self {
let servers = Some(vec![MCPServerConfig {
name: Some("zencoder".to_string()),
url: None,
command: Some(zencoder_config.command),
args: zencoder_config.args,
env: zencoder_config.env,
description: Some("Zencoder MCP server".to_string()),
auth_headers: None,
options: None,
}]);
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MCPClient {
Cursor,
Windsurf,
VSCode,
Claude,
ClaudeCode,
Gemini,
Neovim,
Helix,
Zed,
Zencoder,
}
impl MCPClient {
pub fn name(&self) -> &'static str {
match self {
MCPClient::Cursor => "cursor",
MCPClient::Windsurf => "windsurf",
MCPClient::VSCode => "vscode",
MCPClient::Claude => "claude",
MCPClient::ClaudeCode => "claude-code",
MCPClient::Gemini => "gemini",
MCPClient::Neovim => "neovim",
MCPClient::Helix => "helix",
MCPClient::Zed => "zed",
MCPClient::Zencoder => "zencoder",
}
}
}
static CONFIG_PATHS_CACHE: LazyLock<Vec<(PathBuf, MCPClient)>> =
LazyLock::new(MCPConfigManager::discover_config_paths);
pub struct MCPConfigManager {
config_paths: Vec<(PathBuf, MCPClient)>,
}
impl MCPConfigManager {
fn normalize_url(url: &str) -> String {
let mut normalized = url.trim().to_lowercase();
if normalized.ends_with('/') && normalized != "http://" && normalized != "https://" {
normalized = normalized.trim_end_matches('/').to_string();
}
normalized = normalized
.replace("127.0.0.1", "localhost")
.replace("0.0.0.0", "localhost");
if (normalized.starts_with("http://localhost")
|| normalized.starts_with("https://localhost"))
&& !normalized.contains(':')
{
}
normalized
}
pub fn new() -> Self {
Self {
config_paths: CONFIG_PATHS_CACHE.clone(),
}
}
#[allow(dead_code)] pub fn new_uncached() -> Self {
let paths = Self::discover_config_paths();
Self {
config_paths: paths,
}
}
fn discover_config_paths() -> Vec<(PathBuf, MCPClient)> {
let mut paths = Vec::new();
paths.extend(Self::get_workspace_paths());
let platform = env::consts::OS;
match platform {
"windows" => {
paths.extend(Self::get_windows_paths());
}
"macos" | "darwin" => {
paths.extend(Self::get_macos_paths());
}
_ => {
paths.extend(Self::get_unix_paths());
}
}
paths
}
fn get_workspace_paths() -> Vec<(PathBuf, MCPClient)> {
let mut paths = Vec::new();
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
paths.push((
current_dir.join(".vscode").join("mcp.json"),
MCPClient::VSCode,
));
paths.push((
current_dir.join(".vscode").join("settings.json"),
MCPClient::VSCode,
));
paths.push((
current_dir.join(".cursor").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
current_dir.join(".cursor").join("settings.json"),
MCPClient::Cursor,
));
paths.push((
current_dir.join(".cursor").join("rules").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
current_dir.join(".claude").join("settings.json"),
MCPClient::ClaudeCode,
));
paths.push((
current_dir.join(".claude").join("settings.local.json"),
MCPClient::ClaudeCode,
));
paths.push((
current_dir.join(".claude").join("mcp.json"),
MCPClient::Claude,
));
paths.push((
current_dir.join(".windsurf").join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((
current_dir.join(".windsurf").join("mcp_config.json"),
MCPClient::Windsurf,
));
paths.push((
current_dir.join(".gemini").join("settings.json"),
MCPClient::Gemini,
));
let mut parent = current_dir.parent();
let mut level = 0;
while let Some(dir) = parent {
if level >= 3 {
break;
}
if dir.join(".git").exists()
|| dir.join("package.json").exists()
|| dir.join("Cargo.toml").exists()
|| dir.join("pyproject.toml").exists()
|| dir.join("requirements.txt").exists()
{
paths.push((dir.join(".vscode").join("mcp.json"), MCPClient::VSCode));
paths.push((dir.join(".vscode").join("settings.json"), MCPClient::VSCode));
paths.push((dir.join(".cursor").join("mcp.json"), MCPClient::Cursor));
paths.push((
dir.join(".cursor").join("rules").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
dir.join(".claude").join("settings.json"),
MCPClient::ClaudeCode,
));
paths.push((
dir.join(".claude").join("settings.local.json"),
MCPClient::ClaudeCode,
));
paths.push((dir.join(".claude").join("mcp.json"), MCPClient::Claude));
paths.push((dir.join(".windsurf").join("mcp.json"), MCPClient::Windsurf));
paths.push((
dir.join(".windsurf").join("mcp_config.json"),
MCPClient::Windsurf,
));
paths.push((dir.join(".gemini").join("settings.json"), MCPClient::Gemini));
break; }
parent = dir.parent();
level += 1;
}
paths
}
fn get_windows_paths() -> Vec<(PathBuf, MCPClient)> {
let mut paths = Vec::new();
if let Ok(appdata) = env::var("APPDATA") {
let appdata_path = PathBuf::from(appdata);
paths.push((
appdata_path
.join("Cursor")
.join("User")
.join("globalStorage")
.join("rooveterinaryinc.cursor-mcp")
.join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
appdata_path.join("Cursor").join("User").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
appdata_path.join("Windsurf").join("User").join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((
appdata_path
.join("Codeium")
.join("Windsurf")
.join("mcp_config.json"),
MCPClient::Windsurf,
));
paths.push((
appdata_path.join("Code").join("User").join("mcp.json"),
MCPClient::VSCode,
));
paths.push((
appdata_path.join("Claude").join("mcp.json"),
MCPClient::Claude,
));
} else {
if let Ok(localappdata) = env::var("LOCALAPPDATA") {
let localappdata_path = PathBuf::from(localappdata);
paths.push((
localappdata_path
.join("Cursor")
.join("User")
.join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
localappdata_path
.join("Programs")
.join("Windsurf")
.join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((
localappdata_path
.join("Programs")
.join("Microsoft VS Code")
.join("mcp.json"),
MCPClient::VSCode,
));
}
}
if let Some(home_dir) = dirs::home_dir() {
paths.push((home_dir.join(".cursor").join("mcp.json"), MCPClient::Cursor));
paths.push((home_dir.join(".vscode").join("mcp.json"), MCPClient::VSCode));
paths.push((home_dir.join(".claude").join("mcp.json"), MCPClient::Claude));
paths.push((
home_dir.join(".claude").join("settings.json"),
MCPClient::ClaudeCode,
));
paths.push((
home_dir.join(".gemini").join("settings.json"),
MCPClient::Gemini,
));
let user_appdata = home_dir.join("AppData").join("Roaming");
if user_appdata.exists() {
paths.push((
user_appdata.join("Cursor").join("User").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
user_appdata.join("Code").join("User").join("mcp.json"),
MCPClient::VSCode,
));
paths.push((
user_appdata.join("Code").join("User").join("settings.json"),
MCPClient::VSCode,
));
paths.push((
user_appdata
.join("Claude")
.join("claude_desktop_config.json"),
MCPClient::Claude,
));
paths.push((
user_appdata.join("Windsurf").join("User").join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((
user_appdata
.join("Codeium")
.join("Windsurf")
.join("mcp_config.json"),
MCPClient::Windsurf,
));
if let Ok(program_data) = env::var("PROGRAMDATA") {
paths.push((
PathBuf::from(program_data)
.join("ClaudeCode")
.join("managed-settings.json"),
MCPClient::ClaudeCode,
));
}
}
}
paths
}
fn get_macos_paths() -> Vec<(PathBuf, MCPClient)> {
let mut paths = Vec::new();
if let Some(home_dir) = dirs::home_dir() {
let app_support = home_dir.join("Library").join("Application Support");
paths.push((
app_support
.join("Cursor")
.join("User")
.join("globalStorage")
.join("rooveterinaryinc.cursor-mcp")
.join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
app_support.join("Cursor").join("User").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((home_dir.join(".cursor").join("mcp.json"), MCPClient::Cursor));
paths.push((
app_support.join("Windsurf").join("User").join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((
app_support
.join("Codeium")
.join("Windsurf")
.join("mcp_config.json"),
MCPClient::Windsurf,
));
paths.push((
home_dir
.join(".codeium")
.join("windsurf")
.join("mcp_config.json"),
MCPClient::Windsurf,
));
paths.push((
app_support.join("Code").join("User").join("mcp.json"),
MCPClient::VSCode,
));
paths.push((
app_support.join("Code").join("User").join("settings.json"),
MCPClient::VSCode,
));
paths.push((home_dir.join(".vscode").join("mcp.json"), MCPClient::VSCode));
paths.push((
home_dir.join(".vscode").join("settings.json"),
MCPClient::VSCode,
));
paths.push((
app_support
.join("Claude")
.join("claude_desktop_config.json"),
MCPClient::Claude,
));
paths.push((
app_support
.join("Claude")
.join("User")
.join("claude_desktop_config.json"),
MCPClient::Claude,
));
paths.push((
home_dir.join(".claude").join("settings.json"),
MCPClient::ClaudeCode,
));
paths.push((home_dir.join(".claude").join("mcp.json"), MCPClient::Claude));
paths.push((
home_dir.join(".gemini").join("settings.json"),
MCPClient::Gemini,
));
paths.push((app_support.join("Zed").join("mcp.json"), MCPClient::Zed));
paths.push((
app_support.join("Zencoder").join("mcp.json"),
MCPClient::Zencoder,
));
paths.push((
PathBuf::from("/Library/Application Support/ClaudeCode/managed-settings.json"),
MCPClient::ClaudeCode,
));
let config_dir = home_dir.join(".config");
paths.push((config_dir.join("nvim").join("mcp.json"), MCPClient::Neovim));
paths.push((config_dir.join("helix").join("mcp.json"), MCPClient::Helix));
}
paths
}
fn get_unix_paths() -> Vec<(PathBuf, MCPClient)> {
let mut paths = Vec::new();
if let Some(home_dir) = dirs::home_dir() {
let config_dir = home_dir.join(".config");
paths.push((home_dir.join(".cursor").join("mcp.json"), MCPClient::Cursor));
paths.push((
config_dir.join("Cursor").join("User").join("mcp.json"),
MCPClient::Cursor,
));
paths.push((
home_dir.join(".windsurf").join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((
home_dir
.join(".codeium")
.join("windsurf")
.join("mcp_config.json"),
MCPClient::Windsurf,
));
paths.push((
config_dir.join("Windsurf").join("User").join("mcp.json"),
MCPClient::Windsurf,
));
paths.push((home_dir.join(".vscode").join("mcp.json"), MCPClient::VSCode));
paths.push((
home_dir.join(".vscode").join("settings.json"),
MCPClient::VSCode,
));
paths.push((
config_dir.join("Code").join("User").join("mcp.json"),
MCPClient::VSCode,
));
paths.push((home_dir.join(".claude").join("mcp.json"), MCPClient::Claude));
paths.push((
home_dir.join(".claude").join("settings.json"),
MCPClient::ClaudeCode,
));
paths.push((
home_dir.join(".gemini").join("settings.json"),
MCPClient::Gemini,
));
paths.push((
config_dir.join("Code").join("User").join("settings.json"),
MCPClient::VSCode,
));
paths.push((
home_dir.join(".claude").join("claude_desktop_config.json"),
MCPClient::Claude,
));
paths.push((
config_dir.join("claude").join("claude_desktop_config.json"),
MCPClient::Claude,
));
paths.push((home_dir.join(".claude").join("mcp.json"), MCPClient::Claude));
paths.push((config_dir.join("nvim").join("mcp.json"), MCPClient::Neovim));
paths.push((config_dir.join("helix").join("mcp.json"), MCPClient::Helix));
paths.push((config_dir.join("zed").join("mcp.json"), MCPClient::Zed));
paths.push((
config_dir.join("zencoder").join("mcp.json"),
MCPClient::Zencoder,
));
paths.push((
PathBuf::from("/etc/claude-code/managed-settings.json"),
MCPClient::ClaudeCode,
));
}
paths
}
pub fn detect_client<P: AsRef<Path>>(path: P) -> Option<MCPClient> {
let path = path.as_ref();
let components: Vec<_> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.map(str::to_lowercase)
.collect();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
match filename {
"claude_desktop_config.json" => return Some(MCPClient::Claude),
"settings.json" => {
if components.iter().any(|c| c == ".claude") {
return Some(MCPClient::ClaudeCode);
}
if components
.iter()
.any(|c| c == "code" || c == "vscode" || c == ".vscode")
{
return Some(MCPClient::VSCode);
}
}
"settings.local.json" => {
if components.iter().any(|c| c == ".claude") {
return Some(MCPClient::ClaudeCode);
}
}
"managed-settings.json" => {
if components
.iter()
.any(|c| c == "claudecode" || c == "claude-code")
{
return Some(MCPClient::ClaudeCode);
}
}
_ => {}
}
}
for component in &components {
match component.as_str() {
"cursor" | ".cursor" => return Some(MCPClient::Cursor),
"windsurf" => return Some(MCPClient::Windsurf),
"claude" | ".claude" => return Some(MCPClient::Claude),
"gemini" | ".gemini" => return Some(MCPClient::Gemini),
"zed" => return Some(MCPClient::Zed),
"zencoder" => return Some(MCPClient::Zencoder),
"helix" => return Some(MCPClient::Helix),
"nvim" | "neovim" => return Some(MCPClient::Neovim),
"code" | "vscode" | ".vscode" => return Some(MCPClient::VSCode),
"codeium" | ".codeium" => return Some(MCPClient::Windsurf),
c if c.starts_with("cursor") && !c.contains("vscode") => {
return Some(MCPClient::Cursor)
}
c if c == "microsoft vs code"
|| (c.contains("microsoft") && c.contains("code")) =>
{
return Some(MCPClient::VSCode)
}
_ => {} }
}
let path_str = path.to_string_lossy().to_lowercase();
if path_str.contains("rooveterinaryinc.cursor-mcp") {
return Some(MCPClient::Cursor);
}
None
}
pub fn load_config(&self) -> MCPConfig {
let mut merged_config = MCPConfig::default();
let mut loaded_configs = 0;
let mut failed_configs = Vec::new();
let existing_configs: Vec<_> = self
.config_paths
.iter()
.filter(|(path, _)| path.exists())
.collect();
if !existing_configs.is_empty() {
println!("🔍 Found {} IDE config files:", existing_configs.len());
for (path, client) in existing_configs {
println!(" ✓ {} IDE: {}", client.name(), path.display());
}
println!();
}
for (path, client) in &self.config_paths {
match Self::load_config_from_path(path) {
Ok(config) => {
if let Err(validation_error) = Self::validate_config(&config) {
warn!(
"Invalid MCP configuration in {} ({}): {}",
client.name(),
path.display(),
validation_error
);
failed_configs.push((path.clone(), validation_error));
continue;
}
let server_count = config.servers.as_ref().map(|s| s.len()).unwrap_or(0);
println!(
"📁 {} IDE config: {} ({} servers)",
client.name(),
path.display(),
server_count
);
if let Some(ref servers) = config.servers {
for server in servers {
let server_name = server.name.as_deref().unwrap_or("unnamed");
let server_type = if server.command.is_some() {
"STDIO"
} else {
"HTTP"
};
println!(
" └─ {} [{}]: {}",
server_name,
server_type,
server.to_display_url()
);
}
}
Self::merge_config_with_source(&mut merged_config, &config, client.name());
loaded_configs += 1;
debug!(
"Loaded MCP configuration from {} IDE: {}",
client.name(),
path.display()
);
}
Err(e) => {
if path.exists() {
warn!(
"Failed to parse MCP configuration from {} IDE at {}: {}",
client.name(),
path.display(),
e
);
failed_configs.push((path.clone(), e));
} else {
debug!(
"No MCP configuration found for {} IDE at: {}",
client.name(),
path.display()
);
}
}
}
}
if loaded_configs == 0 && failed_configs.is_empty() {
debug!("No MCP configuration files found in any supported IDE locations");
} else if !failed_configs.is_empty() {
warn!(
"Found {} configuration files with errors",
failed_configs.len()
);
}
merged_config
}
fn try_parse_cursor_compatible_config(content: &str, client_name: &str) -> Option<MCPConfig> {
if let Ok(cursor_config) = serde_json::from_str::<CursorMCPConfig>(content) {
debug!("Parsed as {} configuration format", client_name);
Some(Self::convert_cursor_config(cursor_config))
} else {
None
}
}
pub fn load_config_from_path(path: &Path) -> Result<MCPConfig> {
if !path.exists() {
return Err(anyhow!(
"IDE configuration file does not exist: {}",
path.display()
));
}
let content = fs::read_to_string(path)
.map_err(|e| anyhow!("Failed to read IDE config file {}: {}", path.display(), e))?;
let client = Self::detect_client(path);
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match client {
Some(MCPClient::Cursor) => {
if let Some(config) =
Self::try_parse_cursor_compatible_config(&content, "Cursor MCP")
{
return Ok(config);
}
}
Some(MCPClient::Claude) => {
if filename == "claude_desktop_config.json" {
if let Ok(claude_config) = serde_json::from_str::<ClaudeDesktopConfig>(&content)
{
debug!("Parsed as Claude Desktop configuration format");
return Ok(Self::convert_claude_desktop_config(claude_config));
}
}
else if filename == "mcp.json" {
if let Some(config) =
Self::try_parse_cursor_compatible_config(&content, "Claude MCP")
{
return Ok(config);
}
}
}
Some(MCPClient::ClaudeCode) => {
if filename == "settings.json" || filename == "settings.local.json" {
if let Some(config) =
Self::try_parse_cursor_compatible_config(&content, "Claude Code")
{
return Ok(config);
}
}
}
Some(MCPClient::Windsurf) | Some(MCPClient::Gemini) => {
let client_name = format!("{} MCP", client.as_ref().unwrap().name());
if let Some(config) =
Self::try_parse_cursor_compatible_config(&content, &client_name)
{
return Ok(config);
}
}
Some(MCPClient::VSCode) => {
if filename == "settings.json" {
if let Ok(vscode_config) = serde_json::from_str::<VSCodeSettings>(&content) {
debug!("Parsed as VS Code settings configuration format");
return Ok(Self::convert_vscode_config(vscode_config));
}
}
else if filename == "mcp.json" {
if let Ok(vscode_mcp_config) = serde_json::from_str::<VSCodeMCPConfig>(&content)
{
debug!("Parsed as VS Code MCP configuration format");
return Ok(Self::convert_vscode_mcp_config(vscode_mcp_config));
}
}
}
_ => {}
}
match serde_json::from_str::<MCPConfig>(&content) {
Ok(config) => Ok(config),
Err(e) => {
if let Some(config) =
Self::try_parse_cursor_compatible_config(&content, "Cursor MCP (fallback)")
{
Ok(config)
} else if let Ok(claude_config) =
serde_json::from_str::<ClaudeDesktopConfig>(&content)
{
debug!("Parsed as Claude Desktop configuration format (fallback)");
Ok(Self::convert_claude_desktop_config(claude_config))
} else if let Ok(vscode_config) = serde_json::from_str::<VSCodeSettings>(&content) {
debug!("Parsed as VS Code settings configuration format (fallback)");
Ok(Self::convert_vscode_config(vscode_config))
} else if let Ok(vscode_mcp_config) =
serde_json::from_str::<VSCodeMCPConfig>(&content)
{
debug!("Parsed as VS Code MCP configuration format (fallback)");
Ok(Self::convert_vscode_mcp_config(vscode_mcp_config))
} else {
Err(anyhow!(
"Failed to parse IDE config file {}: {}",
path.display(),
e
))
}
}
}
}
fn convert_cursor_config(cursor_config: CursorMCPConfig) -> MCPConfig {
let servers = cursor_config.mcp_servers.map(|mcp_servers| {
mcp_servers
.into_iter()
.filter_map(|(name, server_config)| {
if let Some(url) = server_config.url {
Some(MCPServerConfig {
name: Some(name),
url: Some(url),
command: None,
args: None,
env: None,
description: server_config.description,
auth_headers: server_config.headers,
options: None,
})
} else if let Some(transport) = &server_config.transport {
let host = transport.host.as_deref().unwrap_or("localhost");
let port = transport.port.unwrap_or(8080);
#[allow(clippy::match_same_arms)]
let scheme = match transport.transport_type.as_deref() {
Some("http" | "streamable-http") => "http",
Some("https") => "https",
_ => "http",
};
let url = format!("{scheme}://{host}:{port}");
Some(MCPServerConfig {
name: Some(name),
url: Some(url),
command: None,
args: None,
env: None,
description: server_config.description,
auth_headers: server_config.headers,
options: None,
})
} else if server_config.command.is_some() {
Some(MCPServerConfig {
name: Some(name.clone()),
url: None, command: server_config.command,
args: server_config.args,
env: server_config.env,
description: server_config.description,
auth_headers: server_config.headers,
options: None,
})
} else {
None
}
})
.collect()
});
MCPConfig {
servers,
options: None, auth_headers: None,
}
}
fn convert_claude_desktop_config(claude_config: ClaudeDesktopConfig) -> MCPConfig {
let servers = claude_config.mcp_servers.map(|mcp_servers| {
mcp_servers
.into_iter()
.filter_map(|(name, server_config)| {
if server_config.disabled.unwrap_or(false) {
return None;
}
let url = if let Some(url) = server_config.url {
url
} else if server_config.command.is_some() {
format!("stdio://{name}")
} else {
return None;
};
Some(MCPServerConfig {
name: Some(name),
url: Some(url),
command: None,
args: None,
env: None,
description: None, auth_headers: server_config.headers,
options: None,
})
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
fn convert_vscode_config(vscode_config: VSCodeSettings) -> MCPConfig {
let servers = vscode_config.mcp_servers.map(|mcp_servers| {
mcp_servers
.into_iter()
.filter_map(|(name, server_config)| {
let is_http = server_config.url.is_some();
let is_stdio = server_config.command.is_some();
if !is_http && !is_stdio {
return None;
}
Some(MCPServerConfig {
name: Some(name),
url: if is_http { server_config.url } else { None },
command: if is_stdio {
server_config.command
} else {
None
},
args: if is_stdio { server_config.args } else { None },
env: server_config.env,
description: None, auth_headers: None,
options: None,
})
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
fn convert_vscode_mcp_config(vscode_mcp_config: VSCodeMCPConfig) -> MCPConfig {
let servers = vscode_mcp_config.servers.map(|servers| {
servers
.into_iter()
.filter_map(|(name, server_config)| {
let is_http = server_config.url.is_some();
let is_stdio = server_config.command.is_some();
if !is_http && !is_stdio {
return None;
}
Some(MCPServerConfig {
name: Some(name),
url: if is_http { server_config.url } else { None },
command: if is_stdio {
server_config.command
} else {
None
},
args: if is_stdio { server_config.args } else { None },
env: server_config.env,
description: None, auth_headers: server_config.headers,
options: None,
})
})
.collect()
});
MCPConfig {
servers,
options: None,
auth_headers: None,
}
}
#[allow(dead_code)]
fn try_parse_with_fallback<F>(
ide_name: &str,
content: &str,
ide_parser: F,
path: &Path,
) -> Result<MCPConfig>
where
F: Fn(&str) -> Result<MCPConfig>,
{
match ide_parser(content) {
Ok(config) => Ok(config),
Err(ide_error) => {
match Self::parse_standard_config(content) {
Ok(config) => {
debug!(
"{} format parsing failed for {}, but standard format succeeded. {} error was: {}",
ide_name,
path.display(),
ide_name,
ide_error
);
Ok(config)
}
Err(standard_error) => {
Err(anyhow!(
"Failed to parse IDE config file {} in both {} format and standard format. {} format error: {}. Standard format error: {}",
path.display(),
ide_name,
ide_name,
ide_error,
standard_error
))
}
}
}
}
}
#[allow(dead_code)]
fn parse_standard_config(content: &str) -> Result<MCPConfig> {
serde_json::from_str(content).map_err(|e| anyhow!("Standard format parsing failed: {}", e))
}
#[allow(dead_code)]
fn parse_vscode_config(content: &str) -> Result<MCPConfig> {
if let Ok(vscode_array_config) = serde_json::from_str::<VSCodeArrayMCPConfig>(content) {
Ok(vscode_array_config.into())
} else if let Ok(vscode_object_config) =
serde_json::from_str::<VSCodeObjectMCPConfig>(content)
{
Ok(vscode_object_config.into())
} else {
let vscode_config: VSCodeMCPConfig = serde_json::from_str(content)?;
Ok(vscode_config.into())
}
}
#[allow(dead_code)]
fn parse_cursor_config(content: &str) -> Result<MCPConfig> {
let cursor_config: CursorMCPConfig = serde_json::from_str(content)?;
Ok(cursor_config.into())
}
#[allow(dead_code)]
fn parse_windsurf_config(content: &str) -> Result<MCPConfig> {
let windsurf_config: WindsurfMCPConfig = serde_json::from_str(content)?;
Ok(windsurf_config.into())
}
#[allow(dead_code)]
fn parse_claude_config(content: &str) -> Result<MCPConfig> {
let claude_config: ClaudeMCPConfig = serde_json::from_str(content)?;
Ok(claude_config.into())
}
#[allow(dead_code)]
fn parse_claude_code_config(content: &str) -> Result<MCPConfig> {
let claude_code_config: ClaudeCodeConfig = serde_json::from_str(content)?;
Ok(claude_code_config.into())
}
#[allow(dead_code)]
fn parse_zed_config(content: &str) -> Result<MCPConfig> {
let zed_config: ZedMCPConfig = serde_json::from_str(content)?;
Ok(zed_config.into())
}
#[allow(dead_code)]
fn parse_zencoder_config(content: &str) -> Result<MCPConfig> {
let zencoder_config: ZencoderMCPConfig = serde_json::from_str(content)?;
Ok(zencoder_config.into())
}
fn merge_config_with_source(base: &mut MCPConfig, other: &MCPConfig, ide_name: &str) {
let mut config_with_source = other.clone();
if let Some(ref mut servers) = config_with_source.servers {
for server in servers.iter_mut() {
let ide_info = format!("IDE:{ide_name}");
match &server.description {
Some(desc) => {
server.description = Some(format!("{desc} [{ide_info}]"));
}
None => {
server.description = Some(format!("[{ide_info}]"));
}
}
}
}
Self::merge_config(base, &config_with_source);
}
fn merge_config(base: &mut MCPConfig, other: &MCPConfig) {
if let Some(other_servers) = &other.servers {
match &mut base.servers {
Some(base_servers) => {
let total_capacity = base_servers.len() + other_servers.len();
let mut server_map: HashMap<String, MCPServerConfig> =
HashMap::with_capacity(total_capacity);
for server in base_servers.drain(..) {
let key = server.dedup_key();
server_map.insert(key, server);
}
for server in other_servers {
let key = server.dedup_key();
server_map.insert(key, server.clone());
}
*base_servers = server_map.into_values().collect();
}
None => {
base.servers = Some(other_servers.clone());
}
}
}
if let Some(other_options) = &other.options {
match &mut base.options {
Some(base_options) => {
if other_options.timeout.is_some() {
base_options.timeout = other_options.timeout;
}
if other_options.http_timeout.is_some() {
base_options.http_timeout = other_options.http_timeout;
}
if other_options.format.is_some() {
base_options.format.clone_from(&other_options.format);
}
if other_options.detailed.is_some() {
base_options.detailed = other_options.detailed;
}
}
None => {
base.options = Some(other_options.clone());
}
}
}
if let Some(other_auth_headers) = &other.auth_headers {
match &mut base.auth_headers {
Some(base_auth_headers) => {
for (key, value) in other_auth_headers {
base_auth_headers.insert(key.clone(), value.clone());
}
}
None => {
base.auth_headers = Some(other_auth_headers.clone());
}
}
}
}
fn validate_server_count(servers: &[MCPServerConfig]) -> Result<()> {
if servers.len() > 100 {
return Err(anyhow!(
"Too many servers configured ({}). Maximum recommended: 100",
servers.len()
));
}
Ok(())
}
fn validate_server_config(server: &MCPServerConfig, server_index: usize) -> Result<()> {
match (&server.url, &server.command) {
(Some(url), None) => {
Self::validate_server_url(url, server_index)?;
}
(None, Some(command)) => {
Self::validate_stdio_server(command, server.args.as_ref(), server_index)?;
}
(Some(_), Some(_)) => {
return Err(anyhow!(
"Server {} cannot have both URL and command specified - choose HTTP (url) or STDIO (command)",
server_index
));
}
(None, None) => {
return Err(anyhow!(
"Server {} must have either URL (for HTTP servers) or command (for STDIO servers) specified",
server_index
));
}
}
if let Some(name) = &server.name {
Self::validate_server_name(name, server_index)?;
}
if let Some(env) = &server.env {
Self::validate_env_vars(env, server_index)?;
}
Ok(())
}
fn validate_stdio_server(
command: &str,
args: Option<&Vec<String>>,
server_index: usize,
) -> Result<()> {
if command.trim().is_empty() {
return Err(anyhow!("Server {} has empty command", server_index));
}
if command.len() > 1024 {
return Err(anyhow!(
"Server {} command too long ({}), maximum 1024 characters",
server_index,
command.len()
));
}
if command.contains('\0') || command.contains('\n') || command.contains('\r') {
return Err(anyhow!(
"Server {} command contains invalid characters",
server_index
));
}
if let Some(args_vec) = args {
if args_vec.len() > 100 {
return Err(anyhow!(
"Server {} has too many arguments ({}), maximum 100",
server_index,
args_vec.len()
));
}
for (arg_index, arg) in args_vec.iter().enumerate() {
if arg.len() > 4096 {
return Err(anyhow!(
"Server {} argument {} too long ({}), maximum 4096 characters",
server_index,
arg_index,
arg.len()
));
}
if arg.contains('\0') {
return Err(anyhow!(
"Server {} argument {} contains null character",
server_index,
arg_index
));
}
}
}
Ok(())
}
fn validate_env_vars(env: &HashMap<String, String>, server_index: usize) -> Result<()> {
if env.len() > 100 {
return Err(anyhow!(
"Server {} has too many environment variables ({}), maximum 100",
server_index,
env.len()
));
}
for (key, value) in env {
if key.trim().is_empty() {
return Err(anyhow!(
"Server {} has empty environment variable name",
server_index
));
}
if key.len() > 1024 {
return Err(anyhow!(
"Server {} environment variable name '{}' too long ({}), maximum 1024 characters",
server_index,
key,
key.len()
));
}
if value.len() > 8192 {
return Err(anyhow!(
"Server {} environment variable value for '{}' too long ({}), maximum 8192 characters",
server_index,
key,
value.len()
));
}
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(anyhow!(
"Server {} environment variable name '{}' contains invalid characters (only alphanumeric and underscore allowed)",
server_index,
key
));
}
if key.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Err(anyhow!(
"Server {} environment variable name '{}' cannot start with a digit",
server_index,
key
));
}
}
Ok(())
}
fn validate_server_url(url_str: &str, server_index: usize) -> Result<()> {
if url_str.is_empty() {
return Err(anyhow!("Server {} has empty URL", server_index));
}
let url_str = url_str.trim();
if url_str.len() > 2048 {
return Err(anyhow!(
"Server {} URL too long ({}), maximum 2048 characters",
server_index,
url_str.len()
));
}
if url_str.starts_with("stdio:") {
return Err(anyhow!(
"Server {} uses deprecated stdio: URL format. Use 'command' and 'args' fields instead of 'url' for STDIO servers",
server_index
));
}
if url_str.starts_with("http://") || url_str.starts_with("https://") {
match Url::parse(url_str) {
Ok(parsed_url) => {
if let Some(port) = parsed_url.port() {
if port == 0 {
return Err(anyhow!(
"Server {} has invalid port 0: {}",
server_index,
url_str
));
}
}
Ok(())
}
Err(e) => Err(anyhow!(
"Server {} has malformed URL '{}': {}",
server_index,
url_str,
e
)),
}
} else {
Err(anyhow!(
"Server {} has invalid URL scheme: {}. Supported: http://, https://, stdio:",
server_index,
url_str
))
}
}
fn validate_server_name(name: &str, server_index: usize) -> Result<()> {
let name = name.trim();
if name.is_empty() {
return Err(anyhow!("Server {} has empty name", server_index));
}
if name.len() > 255 {
return Err(anyhow!(
"Server {} name too long ({}), maximum 255 characters",
server_index,
name.len()
));
}
Ok(())
}
fn validate_auth_headers(
auth_headers: &HashMap<String, String>,
server_index: usize,
) -> Result<()> {
for (header_name, header_value) in auth_headers {
if header_name.trim().is_empty() {
return Err(anyhow!(
"Server {} has empty auth header name",
server_index
));
}
if header_value.trim().is_empty() {
return Err(anyhow!(
"Server {} has empty auth header value for '{}'",
server_index,
header_name
));
}
if header_name.len() > 1024 || header_value.len() > 4096 {
return Err(anyhow!(
"Server {} has auth header that's too long",
server_index
));
}
}
Ok(())
}
fn validate_server_description(description: &str, server_index: usize) -> Result<()> {
if description.len() > 1000 {
return Err(anyhow!(
"Server {} description too long ({}), maximum 1000 characters",
server_index,
description.len()
));
}
Ok(())
}
fn validate_global_auth_headers(global_auth_headers: &HashMap<String, String>) -> Result<()> {
for (header_name, header_value) in global_auth_headers {
if header_name.trim().is_empty() || header_value.trim().is_empty() {
return Err(anyhow!(
"Global auth headers cannot have empty names or values"
));
}
}
Ok(())
}
fn validate_config(config: &MCPConfig) -> Result<()> {
if let Some(servers) = &config.servers {
Self::validate_server_count(servers)?;
let mut seen_dedup_keys = HashMap::new();
let mut seen_names = HashMap::new();
for (i, server) in servers.iter().enumerate() {
Self::validate_server_config(server, i)?;
let dedup_key = server.dedup_key();
if let Some(existing_index) = seen_dedup_keys.get(&dedup_key) {
return Err(anyhow!(
"Duplicate server configuration detected: server {} and server {} both resolve to the same configuration (key: '{}')",
existing_index, i, dedup_key
));
}
seen_dedup_keys.insert(dedup_key, i);
if let Some(name) = &server.name {
Self::validate_server_name(name, i)?;
let name = name.trim();
if let Some(existing_index) = seen_names.get(name) {
return Err(anyhow!(
"Duplicate server name '{}': server {} and server {} both use this name",
name, existing_index, i
));
}
seen_names.insert(name.to_string(), i);
}
if let Some(auth_headers) = &server.auth_headers {
Self::validate_auth_headers(auth_headers, i)?;
}
if let Some(description) = &server.description {
Self::validate_server_description(description, i)?;
}
}
}
if let Some(global_auth_headers) = &config.auth_headers {
Self::validate_global_auth_headers(global_auth_headers)?;
}
Ok(())
}
pub fn has_config_files(&self) -> bool {
self.config_paths.iter().any(|(path, _)| path.exists())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_load_config_from_path() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("test_config.json");
let config_content = r#"{
"servers": [
{
"name": "test-server",
"url": "http://localhost:3000",
"description": "Test server"
}
],
"options": {
"timeout": 60,
"format": "json"
}
}"#;
fs::write(&config_path, config_content).unwrap();
let config = MCPConfigManager::load_config_from_path(&config_path).unwrap();
assert!(config.servers.is_some());
assert_eq!(config.servers.unwrap().len(), 1);
assert!(config.options.is_some());
}
#[test]
fn test_merge_config() {
let mut base = MCPConfig::default();
let other = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("server1".to_string()),
url: Some("http://localhost:3000".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: Some(MCPGlobalOptions {
timeout: Some(60),
http_timeout: None,
format: Some("json".to_string()),
detailed: None,
}),
auth_headers: None,
};
MCPConfigManager::merge_config(&mut base, &other);
assert!(base.servers.is_some());
assert_eq!(base.servers.unwrap().len(), 1);
assert!(base.options.is_some());
}
#[test]
fn test_merge_config_deduplication() {
let mut base = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("server1".to_string()),
url: Some("http://localhost:3000".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
let other = MCPConfig {
servers: Some(vec![
MCPServerConfig {
name: Some("server1-updated".to_string()),
url: Some("http://localhost:3000".to_string()), command: None,
args: None,
env: None,
description: Some("Updated server".to_string()),
auth_headers: None,
options: None,
},
MCPServerConfig {
name: Some("server2".to_string()),
url: Some("http://localhost:4000".to_string()), command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
},
]),
options: None,
auth_headers: None,
};
MCPConfigManager::merge_config(&mut base, &other);
let servers = base.servers.unwrap();
assert_eq!(servers.len(), 2);
let updated_server = servers
.iter()
.find(|s| s.url.as_deref() == Some("http://localhost:3000"))
.unwrap();
assert_eq!(updated_server.name.as_ref().unwrap(), "server1-updated");
assert_eq!(
updated_server.description.as_ref().unwrap(),
"Updated server"
);
let new_server = servers
.iter()
.find(|s| s.url.as_deref() == Some("http://localhost:4000"))
.unwrap();
assert_eq!(new_server.name.as_ref().unwrap(), "server2");
}
#[test]
fn test_detect_client() {
assert_eq!(
MCPConfigManager::detect_client("/home/user/.cursor/mcp.json"),
Some(MCPClient::Cursor)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.codeium/windsurf/mcp_config.json"),
Some(MCPClient::Windsurf)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.vscode/mcp.json"),
Some(MCPClient::VSCode)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.claude/mcp.json"),
Some(MCPClient::Claude)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.config/nvim/mcp.json"),
Some(MCPClient::Neovim)
);
assert_eq!(
MCPConfigManager::detect_client("/some/unknown/path.json"),
None
);
}
#[test]
fn test_validate_config() {
let valid_config = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("test".to_string()),
url: Some("http://localhost:3000".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
assert!(MCPConfigManager::validate_config(&valid_config).is_ok());
let invalid_config = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("test".to_string()),
url: Some(String::new()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
assert!(MCPConfigManager::validate_config(&invalid_config).is_err());
let invalid_config2 = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("test".to_string()),
url: Some("not-a-url".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
assert!(MCPConfigManager::validate_config(&invalid_config2).is_err());
}
#[test]
fn test_mcp_client_enum() {
assert_eq!(MCPClient::Cursor.name(), "cursor");
assert_eq!(MCPClient::Windsurf.name(), "windsurf");
assert_eq!(MCPClient::VSCode.name(), "vscode");
assert_eq!(MCPClient::Claude.name(), "claude");
assert_eq!(MCPClient::Neovim.name(), "neovim");
assert_eq!(MCPClient::Helix.name(), "helix");
assert_eq!(MCPClient::Zed.name(), "zed");
assert_eq!(MCPClient::Zencoder.name(), "zencoder");
}
#[test]
fn test_url_normalization() {
assert_eq!(
MCPConfigManager::normalize_url("HTTP://LOCALHOST:3000/"),
"http://localhost:3000"
);
assert_eq!(
MCPConfigManager::normalize_url("http://127.0.0.1:3000"),
"http://localhost:3000"
);
assert_eq!(
MCPConfigManager::normalize_url("https://0.0.0.0:8080/"),
"https://localhost:8080"
);
assert_eq!(MCPConfigManager::normalize_url("stdio:test"), "stdio:test");
assert_eq!(
MCPConfigManager::normalize_url("http://example.com/path/"),
"http://example.com/path"
);
}
#[test]
fn test_enhanced_client_detection() {
use std::path::PathBuf;
assert_eq!(
MCPConfigManager::detect_client(PathBuf::from(
"/Applications/Cursor.app/Contents/mcp.json"
)),
Some(MCPClient::Cursor)
);
assert_eq!(
MCPConfigManager::detect_client(PathBuf::from(
"C:/Users/test/AppData/Roaming/Code/User/mcp.json"
)),
Some(MCPClient::VSCode)
);
assert_eq!(
MCPConfigManager::detect_client(PathBuf::from(
"/home/user/.config/rooveterinaryinc.cursor-mcp/config.json"
)),
Some(MCPClient::Cursor)
);
assert_eq!(
MCPConfigManager::detect_client(PathBuf::from(
"/home/user/my-code-project/config.json"
)),
None
);
}
#[test]
fn test_enhanced_validation() {
let config_with_duplicate_urls = MCPConfig {
servers: Some(vec![
MCPServerConfig {
name: Some("server1".to_string()),
url: Some("http://localhost:3000".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
},
MCPServerConfig {
name: Some("server2".to_string()),
url: Some("HTTP://LOCALHOST:3000/".to_string()), command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
},
]),
options: None,
auth_headers: None,
};
assert!(MCPConfigManager::validate_config(&config_with_duplicate_urls).is_err());
let config_with_duplicate_names = MCPConfig {
servers: Some(vec![
MCPServerConfig {
name: Some("same-name".to_string()),
url: Some("http://localhost:3000".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
},
MCPServerConfig {
name: Some("same-name".to_string()),
url: Some("http://localhost:4000".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
},
]),
options: None,
auth_headers: None,
};
assert!(MCPConfigManager::validate_config(&config_with_duplicate_names).is_err());
let config_with_invalid_port = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("test".to_string()),
url: Some("http://localhost:0".to_string()),
command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
assert!(MCPConfigManager::validate_config(&config_with_invalid_port).is_err());
}
#[test]
fn test_platform_detection() {
let paths = MCPConfigManager::discover_config_paths();
assert!(
!paths.is_empty(),
"Should discover some configuration paths"
);
}
#[test]
fn test_merge_config_with_normalization() {
let mut base = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("server1".to_string()),
url: Some("http://localhost:3000/".to_string()), command: None,
args: None,
env: None,
description: None,
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
let other = MCPConfig {
servers: Some(vec![MCPServerConfig {
name: Some("server1-updated".to_string()),
url: Some("HTTP://LOCALHOST:3000".to_string()), command: None,
args: None,
env: None,
description: Some("Updated".to_string()),
auth_headers: None,
options: None,
}]),
options: None,
auth_headers: None,
};
MCPConfigManager::merge_config(&mut base, &other);
let servers = base.servers.unwrap();
assert_eq!(servers.len(), 1); assert_eq!(servers[0].name.as_ref().unwrap(), "server1-updated");
assert_eq!(servers[0].description.as_ref().unwrap(), "Updated");
}
#[test]
fn test_vscode_config_parsing() {
let vscode_content = r#"{
"servers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"gallery": true
},
"local-fs": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"description": "Local filesystem access"
}
},
"inputs": []
}"#;
let result = MCPConfigManager::parse_vscode_config(vscode_content);
assert!(
result.is_ok(),
"Failed to parse VS Code config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 2);
let github_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("github"))
.unwrap();
assert_eq!(
github_server.url,
Some("https://api.githubcopilot.com/mcp/".to_string())
);
let fs_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("local-fs"))
.unwrap();
assert_eq!(fs_server.url, None);
assert_eq!(
fs_server.description,
Some("Local filesystem access".to_string())
);
}
#[test]
fn test_zed_config_parsing() {
let zed_content = r#"{
"context_servers": [
{
"mcp-server-git": {
"command": {
"path": "uvx",
"args": ["mcp-server-git"]
}
}
},
{
"filesystem": {
"command": {
"path": "node",
"args": ["/path/to/filesystem-server.js", "/tmp"]
}
}
}
]
}"#;
let result = MCPConfigManager::parse_zed_config(zed_content);
assert!(
result.is_ok(),
"Failed to parse Zed config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 2);
let git_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("mcp-server-git"))
.unwrap();
assert_eq!(git_server.url, None);
assert!(git_server
.description
.as_ref()
.unwrap()
.contains("uvx mcp-server-git"));
let fs_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("filesystem"))
.unwrap();
assert_eq!(fs_server.url, None);
assert!(fs_server
.description
.as_ref()
.unwrap()
.contains("node /path/to/filesystem-server.js /tmp"));
}
#[test]
fn test_zencoder_config_parsing() {
let zencoder_content = r#"{
"command": "uvx",
"args": ["mcp-server-git", "--repository", "path/to/git/repo"]
}"#;
let result = MCPConfigManager::parse_zencoder_config(zencoder_content);
assert!(
result.is_ok(),
"Failed to parse Zencoder config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 1);
let zencoder_server = &servers[0];
assert_eq!(zencoder_server.name.as_deref(), Some("zencoder"));
assert_eq!(zencoder_server.url, None);
assert_eq!(zencoder_server.command.as_deref(), Some("uvx"));
assert_eq!(
zencoder_server.args,
Some(vec![
"mcp-server-git".to_string(),
"--repository".to_string(),
"path/to/git/repo".to_string()
])
);
assert_eq!(
zencoder_server.description.as_deref(),
Some("Zencoder MCP server")
);
}
#[test]
fn test_cursor_config_parsing() {
let cursor_content = r#"{
"mcpServers": {
"airbnb": {
"command": "npx",
"args": ["-y", "@openbnb/mcp-server-airbnb"]
},
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
},
"time": {
"command": "uvx",
"args": ["mcp-server-time"],
"env": {
"TZ": "UTC"
}
}
}
}"#;
let result = MCPConfigManager::parse_cursor_config(cursor_content);
assert!(
result.is_ok(),
"Failed to parse Cursor config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 3);
let airbnb_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("airbnb"))
.unwrap();
assert_eq!(airbnb_server.command, Some("npx".to_string()));
assert_eq!(
airbnb_server.args,
Some(vec![
"-y".to_string(),
"@openbnb/mcp-server-airbnb".to_string()
])
);
assert_eq!(airbnb_server.url, None);
let time_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("time"))
.unwrap();
assert_eq!(time_server.command, Some("uvx".to_string()));
assert!(time_server.env.is_some());
let env = time_server.env.as_ref().unwrap();
assert_eq!(env.get("TZ"), Some(&"UTC".to_string()));
}
#[test]
fn test_windsurf_config_parsing() {
let windsurf_content = r#"{
"servers": {
"git": {
"command": "uvx",
"args": ["mcp-server-git"],
"type": "stdio"
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"description": "File system access",
"env": {
"NODE_ENV": "development"
}
}
},
"global": {
"timeout": 30
}
}"#;
let result = MCPConfigManager::parse_windsurf_config(windsurf_content);
assert!(
result.is_ok(),
"Failed to parse Windsurf config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 2);
let git_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("git"))
.unwrap();
assert_eq!(git_server.command, Some("uvx".to_string()));
assert_eq!(git_server.args, Some(vec!["mcp-server-git".to_string()]));
let fs_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("filesystem"))
.unwrap();
assert_eq!(
fs_server.description,
Some("File system access".to_string())
);
assert!(fs_server.env.is_some());
}
#[test]
fn test_claude_desktop_config_parsing() {
let claude_content = r#"{
"mcpServers": {
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "your-api-key"
}
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/username/Desktop"],
"type": "stdio"
}
}
}"#;
let result = MCPConfigManager::parse_claude_config(claude_content);
assert!(
result.is_ok(),
"Failed to parse Claude Desktop config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 2);
let brave_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("brave-search"))
.unwrap();
assert_eq!(brave_server.command, Some("npx".to_string()));
assert!(brave_server.env.is_some());
let env = brave_server.env.as_ref().unwrap();
assert_eq!(env.get("BRAVE_API_KEY"), Some(&"your-api-key".to_string()));
let fs_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("filesystem"))
.unwrap();
assert_eq!(fs_server.command, Some("npx".to_string()));
assert!(fs_server
.args
.as_ref()
.unwrap()
.contains(&"/Users/username/Desktop".to_string()));
}
#[test]
fn test_claude_code_config_parsing() {
let claude_code_content = r#"{
"numStartups": 36,
"installMethod": "unknown",
"mcpServers": {
"sequential-thinking": {
"type": "stdio",
"command": "npx",
"args": ["-y", "u/modelcontextprotocol/server-sequential-thinking"],
"env": {}
},
"filesystem": {
"type": "stdio",
"command": "uvx",
"args": ["mcp-server-filesystem", "/tmp"],
"env": {
"DEBUG": "true"
}
}
}
}"#;
let result = MCPConfigManager::parse_claude_code_config(claude_code_content);
assert!(
result.is_ok(),
"Failed to parse Claude Code config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 2);
let seq_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("sequential-thinking"))
.unwrap();
assert_eq!(seq_server.command, Some("npx".to_string()));
assert_eq!(
seq_server.args,
Some(vec![
"-y".to_string(),
"u/modelcontextprotocol/server-sequential-thinking".to_string()
])
);
assert_eq!(seq_server.url, None);
let fs_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("filesystem"))
.unwrap();
assert_eq!(fs_server.command, Some("uvx".to_string()));
assert!(fs_server.env.is_some());
let env = fs_server.env.as_ref().unwrap();
assert_eq!(env.get("DEBUG"), Some(&"true".to_string()));
}
#[test]
fn test_vscode_array_config_parsing() {
let vscode_array_content = r#"{
"servers": [
{
"name": "time",
"command": "uvx",
"args": ["mcp-server-time"]
},
{
"name": "everything",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
},
{
"name": "git",
"command": "uvx",
"args": ["mcp-server-git"]
},
{
"name": "Neon",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.neon.tech/mcp"]
}
]
}"#;
let result = MCPConfigManager::parse_vscode_config(vscode_array_content);
assert!(
result.is_ok(),
"Failed to parse VS Code array config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 4);
let time_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("time"))
.unwrap();
assert_eq!(time_server.command, Some("uvx".to_string()));
assert_eq!(time_server.args, Some(vec!["mcp-server-time".to_string()]));
let neon_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("Neon"))
.unwrap();
assert_eq!(neon_server.command, Some("npx".to_string()));
assert!(neon_server
.args
.as_ref()
.unwrap()
.contains(&"https://mcp.neon.tech/mcp".to_string()));
}
#[test]
fn test_client_path_detection_claude_code() {
assert_eq!(
MCPConfigManager::detect_client("/home/user/.claude/settings.json"),
Some(MCPClient::ClaudeCode)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.claude/settings.local.json"),
Some(MCPClient::ClaudeCode)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.claude/mcp.json"),
Some(MCPClient::Claude)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.codeium/windsurf/mcp_config.json"),
Some(MCPClient::Windsurf)
);
}
#[test]
fn test_gemini_config_parsing() {
let gemini_content = r#"{
"mcpServers": {
"github": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_example_personal_access_token12345"
}
},
"gitlab": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-gitlab"
]
},
"cloudflare-observability": {
"command": "npx",
"args": ["mcp-remote", "https://observability.mcp.cloudflare.com/sse"]
},
"cloudflare-bindings": {
"command": "npx",
"args": ["mcp-remote", "https://bindings.mcp.cloudflare.com/sse"]
}
}
}"#;
let result = MCPConfigManager::parse_cursor_config(gemini_content);
assert!(
result.is_ok(),
"Failed to parse Gemini config: {:?}",
result.err()
);
let config = result.unwrap();
assert!(config.servers.is_some());
let servers = config.servers.unwrap();
assert_eq!(servers.len(), 4);
let github_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("github"))
.unwrap();
assert_eq!(github_server.command, Some("npx".to_string()));
assert_eq!(
github_server.args,
Some(vec![
"-y".to_string(),
"@modelcontextprotocol/server-github".to_string()
])
);
assert_eq!(github_server.url, None);
assert!(github_server.env.is_some());
let env = github_server.env.as_ref().unwrap();
assert_eq!(
env.get("GITHUB_PERSONAL_ACCESS_TOKEN"),
Some(&"ghp_example_personal_access_token12345".to_string())
);
let gitlab_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("gitlab"))
.unwrap();
assert_eq!(gitlab_server.command, Some("npx".to_string()));
assert_eq!(
gitlab_server.args,
Some(vec![
"-y".to_string(),
"@modelcontextprotocol/server-gitlab".to_string()
])
);
let cf_obs_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("cloudflare-observability"))
.unwrap();
assert_eq!(cf_obs_server.command, Some("npx".to_string()));
assert_eq!(
cf_obs_server.args,
Some(vec![
"mcp-remote".to_string(),
"https://observability.mcp.cloudflare.com/sse".to_string()
])
);
let cf_bind_server = servers
.iter()
.find(|s| s.name.as_deref() == Some("cloudflare-bindings"))
.unwrap();
assert_eq!(cf_bind_server.command, Some("npx".to_string()));
assert_eq!(
cf_bind_server.args,
Some(vec![
"mcp-remote".to_string(),
"https://bindings.mcp.cloudflare.com/sse".to_string()
])
);
}
#[test]
fn test_client_path_detection_gemini() {
assert_eq!(
MCPConfigManager::detect_client("/home/user/.gemini/settings.json"),
Some(MCPClient::Gemini)
);
assert_eq!(
MCPConfigManager::detect_client("/Users/user/.gemini/settings.json"),
Some(MCPClient::Gemini)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.claude/settings.json"),
Some(MCPClient::ClaudeCode)
);
assert_eq!(
MCPConfigManager::detect_client("/home/user/.codeium/windsurf/mcp_config.json"),
Some(MCPClient::Windsurf)
);
}
#[test]
fn test_client_name_mappings_complete() {
assert_eq!(MCPClient::Cursor.name(), "cursor");
assert_eq!(MCPClient::Windsurf.name(), "windsurf");
assert_eq!(MCPClient::VSCode.name(), "vscode");
assert_eq!(MCPClient::Claude.name(), "claude");
assert_eq!(MCPClient::ClaudeCode.name(), "claude-code");
assert_eq!(MCPClient::Gemini.name(), "gemini");
assert_eq!(MCPClient::Neovim.name(), "neovim");
assert_eq!(MCPClient::Helix.name(), "helix");
assert_eq!(MCPClient::Zed.name(), "zed");
assert_eq!(MCPClient::Zencoder.name(), "zencoder");
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScannerConfig {
pub llm: LLMConfig,
pub scanner: ScannerSettings,
pub security: SecurityConfig,
pub logging: LoggingConfig,
pub performance: PerformanceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMConfig {
pub provider: String,
pub model: String,
pub base_url: String,
pub api_key: String,
pub timeout: u64,
pub max_tokens: u32,
pub temperature: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScannerSettings {
pub http_timeout: u64,
pub scan_timeout: u64,
pub detailed: bool,
pub format: String,
pub parallel: bool,
pub max_retries: u32,
pub retry_delay_ms: u64,
pub llm_batch_size: u32,
pub enable_yara: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
pub enabled: bool,
pub min_severity: String,
pub checks: SecurityChecks,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct SecurityChecks {
pub tool_poisoning: bool,
pub secrets_leakage: bool,
pub sql_injection: bool,
pub command_injection: bool,
pub path_traversal: bool,
pub auth_bypass: bool,
pub prompt_injection: bool,
pub pii_leakage: bool,
pub jailbreak: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
pub colored: bool,
pub timestamps: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceConfig {
pub tracking: bool,
pub slow_threshold_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolRefreshConfig {
pub enabled: bool,
pub interval_hours: u64,
pub start_time: String,
pub servers: Vec<ToolRefreshServerConfig>,
#[serde(default)]
pub rate_limit: RateLimitConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
pub max_concurrent: usize,
pub delay_between_servers_ms: u64,
pub max_requests_per_minute: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolRefreshServerConfig {
pub url: String,
#[serde(default)]
pub auth_headers: HashMap<String, String>,
#[serde(default = "default_refresh_timeout")]
pub timeout: u64,
}
impl ToolRefreshServerConfig {
#[allow(dead_code)] pub fn with_env_mappings(mut self) -> Self {
self.auth_headers = apply_env_mappings(self.auth_headers);
self
}
}
fn default_refresh_timeout() -> u64 {
30
}
pub fn apply_env_mappings(mut headers: HashMap<String, String>) -> HashMap<String, String> {
if let Ok(llm_api_key) = std::env::var("LLM_API_KEY") {
if !headers.contains_key("Authorization") && !headers.contains_key("authorization") {
headers.insert("Authorization".to_string(), format!("Bearer {llm_api_key}"));
debug!("Applied LLM_API_KEY environment variable to Authorization header");
}
}
if let Ok(api_key) = std::env::var("API_KEY") {
if !headers.contains_key("Authorization") && !headers.contains_key("authorization") {
headers.insert("Authorization".to_string(), format!("Bearer {api_key}"));
debug!("Applied API_KEY environment variable to Authorization header");
}
}
if let Ok(x_api_key) = std::env::var("X_API_KEY") {
if !headers.contains_key("X-API-Key") && !headers.contains_key("x-api-key") {
headers.insert("X-API-Key".to_string(), x_api_key);
debug!("Applied X_API_KEY environment variable to X-API-Key header");
}
}
headers
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
max_concurrent: 3, delay_between_servers_ms: 1000, max_requests_per_minute: 10, }
}
}
impl Default for ToolRefreshConfig {
fn default() -> Self {
Self {
enabled: true,
interval_hours: 24, start_time: "02:00".to_string(), servers: Vec::new(),
rate_limit: RateLimitConfig::default(),
}
}
}
impl Default for ScannerConfig {
fn default() -> Self {
Self {
llm: LLMConfig {
provider: "openai".to_string(),
model: "gpt-4o".to_string(),
base_url: "https://api.openai.com/v1/chat/completions".to_string(),
api_key: String::new(),
timeout: 30,
max_tokens: 4000,
temperature: 0.1,
},
scanner: ScannerSettings {
http_timeout: 30,
scan_timeout: 60,
detailed: false,
format: "table".to_string(),
parallel: true,
max_retries: 3,
retry_delay_ms: 1000,
llm_batch_size: 10,
enable_yara: true,
},
security: SecurityConfig {
enabled: true,
min_severity: "low".to_string(),
checks: SecurityChecks {
tool_poisoning: true,
secrets_leakage: true,
sql_injection: true,
command_injection: true,
path_traversal: true,
auth_bypass: true,
prompt_injection: true,
pii_leakage: true,
jailbreak: true,
},
},
logging: LoggingConfig {
level: "info".to_string(),
colored: true,
timestamps: true,
},
performance: PerformanceConfig {
tracking: true,
slow_threshold_ms: 5000,
},
}
}
}
pub struct ScannerConfigManager {
config_path: PathBuf,
}
impl ScannerConfigManager {
pub fn new() -> Self {
let config_path = PathBuf::from("config.yaml");
Self { config_path }
}
pub fn load_config(&self) -> Result<ScannerConfig> {
if !self.config_path.exists() {
debug!("No config.yaml found, using default configuration");
return Ok(ScannerConfig::default());
}
let content = fs::read_to_string(&self.config_path)
.map_err(|e| anyhow!("Failed to read config.yaml: {}", e))?;
let expanded_content = Self::expand_env_vars(&content)?;
let config: ScannerConfig = serde_yaml::from_str(&expanded_content)
.map_err(|e| anyhow!("Failed to parse config.yaml: {}", e))?;
debug!("Loaded configuration from config.yaml");
Ok(config)
}
fn expand_env_vars(content: &str) -> Result<String> {
use regex::Regex;
let env_var_regex = Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)(:-([^}]*))?\}")
.map_err(|e| anyhow!("Failed to compile environment variable regex: {}", e))?;
let result = env_var_regex.replace_all(content, |caps: ®ex::Captures| {
let var_name = &caps[1];
let default_value = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let replacement = match std::env::var(var_name) {
Ok(value) if !value.is_empty() => value,
_ => default_value.to_string(),
};
debug!(
"Expanding environment variable: {} -> {}",
var_name,
if replacement.is_empty() {
"<empty>"
} else {
"<set>"
}
);
replacement
});
Ok(result.into_owned())
}
pub fn save_config(&self, config: &ScannerConfig) -> Result<()> {
let content = serde_yaml::to_string(config)
.map_err(|e| anyhow!("Failed to serialize configuration: {}", e))?;
fs::write(&self.config_path, content)
.map_err(|e| anyhow!("Failed to write config.yaml: {}", e))?;
debug!("Saved configuration to config.yaml");
Ok(())
}
pub fn has_config_file(&self) -> bool {
self.config_path.exists()
}
}
#[cfg(test)]
mod scanner_config_tests {
use super::*;
use std::env;
#[test]
fn test_expand_env_vars_with_defaults() {
let content = r#"
llm:
provider: ${LLM_PROVIDER:-openai}
model: ${LLM_MODEL:-gpt-4o}
base_url: ${LLM_URL:-https://api.openai.com/v1/chat/completions}
api_key: ${LLM_API_KEY:-}
"#;
let result = ScannerConfigManager::expand_env_vars(content).unwrap();
assert!(result.contains("provider: openai"));
assert!(result.contains("model: gpt-4o"));
assert!(result.contains("base_url: https://api.openai.com/v1/chat/completions"));
assert!(result.contains("api_key: "));
}
#[test]
fn test_expand_env_vars_with_env_values() {
env::set_var("TEST_LLM_PROVIDER", "anthropic");
env::set_var("TEST_LLM_MODEL", "claude-3");
env::set_var("TEST_LLM_URL", "https://api.anthropic.com/v1/messages");
env::set_var("TEST_LLM_API_KEY", "test-key-123");
let content = r#"
llm:
provider: ${TEST_LLM_PROVIDER:-openai}
model: ${TEST_LLM_MODEL:-gpt-4o}
base_url: ${TEST_LLM_URL:-https://api.openai.com/v1/chat/completions}
api_key: ${TEST_LLM_API_KEY:-}
"#;
let result = ScannerConfigManager::expand_env_vars(content).unwrap();
assert!(result.contains("provider: anthropic"));
assert!(result.contains("model: claude-3"));
assert!(result.contains("base_url: https://api.anthropic.com/v1/messages"));
assert!(result.contains("api_key: test-key-123"));
env::remove_var("TEST_LLM_PROVIDER");
env::remove_var("TEST_LLM_MODEL");
env::remove_var("TEST_LLM_URL");
env::remove_var("TEST_LLM_API_KEY");
}
#[test]
fn test_expand_env_vars_mixed_content() {
env::set_var("TEST_MIXED_VAR", "test-value");
let content = r#"
normal_field: regular_value
env_field: ${TEST_MIXED_VAR:-default}
another_field: ${NONEXISTENT_VAR:-fallback}
"#;
let result = ScannerConfigManager::expand_env_vars(content).unwrap();
assert!(result.contains("normal_field: regular_value"));
assert!(result.contains("env_field: test-value"));
assert!(result.contains("another_field: fallback"));
env::remove_var("TEST_MIXED_VAR");
}
#[test]
fn test_config_loading_with_env_vars() {
env::set_var("TEST_CONFIG_LLM_PROVIDER", "test-provider");
env::set_var("TEST_CONFIG_LLM_MODEL", "test-model");
env::set_var("TEST_CONFIG_LLM_API_KEY", "test-key");
let config_content = r#"
llm:
provider: ${TEST_CONFIG_LLM_PROVIDER:-openai}
model: ${TEST_CONFIG_LLM_MODEL:-gpt-4o}
base_url: ${TEST_CONFIG_LLM_URL:-https://api.openai.com/v1/chat/completions}
api_key: ${TEST_CONFIG_LLM_API_KEY:-}
timeout: 30
max_tokens: 4000
temperature: 0.1
scanner:
http_timeout: 30
scan_timeout: 60
detailed: false
format: table
parallel: true
max_retries: 3
retry_delay_ms: 1000
llm_batch_size: 10
enable_yara: true
security:
enabled: true
min_severity: low
checks:
tool_poisoning: true
secrets_leakage: true
sql_injection: true
command_injection: true
path_traversal: true
auth_bypass: true
prompt_injection: true
pii_leakage: true
jailbreak: true
logging:
level: warn
colored: true
timestamps: true
performance:
tracking: true
slow_threshold_ms: 5000
"#;
let expanded = ScannerConfigManager::expand_env_vars(config_content).unwrap();
let config: ScannerConfig = serde_yaml::from_str(&expanded).unwrap();
assert_eq!(config.llm.provider, "test-provider");
assert_eq!(config.llm.model, "test-model");
assert_eq!(
config.llm.base_url,
"https://api.openai.com/v1/chat/completions"
); assert_eq!(config.llm.api_key, "test-key");
env::remove_var("TEST_CONFIG_LLM_PROVIDER");
env::remove_var("TEST_CONFIG_LLM_MODEL");
env::remove_var("TEST_CONFIG_LLM_API_KEY");
}
#[test]
fn test_security_scanner_with_env_config() {
use crate::security::SecurityScanner;
env::set_var("TEST_SECURITY_LLM_PROVIDER", "test-provider");
env::set_var("TEST_SECURITY_LLM_MODEL", "test-model");
env::set_var("TEST_SECURITY_LLM_URL", "https://test.api.com/v1/chat");
env::set_var("TEST_SECURITY_LLM_API_KEY", "test-security-key");
let config_content = r#"
llm:
provider: ${TEST_SECURITY_LLM_PROVIDER:-openai}
model: ${TEST_SECURITY_LLM_MODEL:-gpt-4o}
base_url: ${TEST_SECURITY_LLM_URL:-https://api.openai.com/v1/chat/completions}
api_key: ${TEST_SECURITY_LLM_API_KEY:-}
timeout: 30
max_tokens: 4000
temperature: 0.1
scanner:
http_timeout: 30
scan_timeout: 60
detailed: false
format: table
parallel: true
max_retries: 3
retry_delay_ms: 1000
llm_batch_size: 10
enable_yara: true
security:
enabled: true
min_severity: low
checks:
tool_poisoning: true
secrets_leakage: true
sql_injection: true
command_injection: true
path_traversal: true
auth_bypass: true
prompt_injection: true
pii_leakage: true
jailbreak: true
logging:
level: warn
colored: true
timestamps: true
performance:
tracking: true
slow_threshold_ms: 5000
"#;
let expanded = ScannerConfigManager::expand_env_vars(config_content).unwrap();
let config: ScannerConfig = serde_yaml::from_str(&expanded).unwrap();
let security_scanner = SecurityScanner::with_config(config);
assert_eq!(security_scanner.model_name, "test-model");
assert_eq!(
security_scanner.model_endpoint,
Some("https://test.api.com/v1/chat".to_string())
);
assert_eq!(
security_scanner.api_key,
Some("test-security-key".to_string())
);
env::remove_var("TEST_SECURITY_LLM_PROVIDER");
env::remove_var("TEST_SECURITY_LLM_MODEL");
env::remove_var("TEST_SECURITY_LLM_URL");
env::remove_var("TEST_SECURITY_LLM_API_KEY");
}
}