use meerkat_core::mcp_config::{
McpConfig, McpScope, McpServerConfig, McpTransportConfig, McpTransportKind, find_project_mcp,
project_mcp_path, user_mcp_path,
};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use toml_edit::{Array, DocumentMut, Item, Table};
fn truncate_str(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() > max_chars {
let truncated: String = chars[..max_chars.saturating_sub(3)].iter().collect();
format!("{truncated}...")
} else {
s.to_string()
}
}
fn mask_secret(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= 4 {
"****".to_string()
} else {
let prefix: String = chars[..2].iter().collect();
let suffix: String = chars[chars.len() - 2..].iter().collect();
format!("{prefix}...{suffix}")
}
}
fn format_server_target(server: &McpServerConfig) -> (McpTransportKind, String) {
match &server.transport {
McpTransportConfig::Stdio(stdio) => {
let cmd = if stdio.args.is_empty() {
stdio.command.clone()
} else {
format!("{} {}", stdio.command, stdio.args.join(" "))
};
(McpTransportKind::Stdio, cmd)
}
McpTransportConfig::Http(http) => {
let kind = server.transport_kind();
(kind, http.url.clone())
}
}
}
fn transport_label(kind: McpTransportKind) -> &'static str {
match kind {
McpTransportKind::Stdio => "stdio",
McpTransportKind::StreamableHttp => "streamable-http",
McpTransportKind::Sse => "sse",
}
}
pub fn build_server_config(
name: String,
transport: Option<McpTransportKind>,
url: Option<String>,
headers: Vec<String>,
command: Vec<String>,
env: Vec<String>,
) -> anyhow::Result<McpServerConfig> {
let server = match (transport, url, command.is_empty()) {
(Some(McpTransportKind::Stdio), _, false) => {
let env_map = parse_env_vars(&env)?;
McpServerConfig::stdio(name, command[0].clone(), command[1..].to_vec(), env_map)
}
(Some(McpTransportKind::Stdio), _, true) => {
anyhow::bail!(
"Stdio transport requires a command. Usage: rkat mcp add <name> -t stdio -- <command> [args...]"
);
}
(Some(McpTransportKind::StreamableHttp), Some(url), _) => {
let header_map = parse_headers(&headers)?;
McpServerConfig::streamable_http(name, url, header_map)
}
(Some(McpTransportKind::Sse), Some(url), _) => {
let header_map = parse_headers(&headers)?;
McpServerConfig::sse(name, url, header_map)
}
(Some(McpTransportKind::StreamableHttp | McpTransportKind::Sse), None, _) => {
anyhow::bail!(
"HTTP/SSE transport requires --url. Usage: rkat mcp add <name> -t http --url <url>"
);
}
(None, Some(url), _) => {
let header_map = parse_headers(&headers)?;
McpServerConfig::streamable_http(name, url, header_map)
}
(None, None, false) => {
let env_map = parse_env_vars(&env)?;
McpServerConfig::stdio(name, command[0].clone(), command[1..].to_vec(), env_map)
}
(None, None, true) => {
anyhow::bail!(
"Either command or URL is required.\n\
Stdio: rkat mcp add <name> -- <command> [args...]\n\
HTTP: rkat mcp add <name> --url <url>"
);
}
};
Ok(server)
}
pub async fn add_server(
name: String,
transport: Option<McpTransportKind>,
url: Option<String>,
headers: Vec<String>,
command: Vec<String>,
env: Vec<String>,
project_scope: bool,
) -> anyhow::Result<()> {
let scope = if project_scope {
McpScope::Project
} else {
McpScope::User
};
if McpConfig::server_exists(&name, scope).await? {
anyhow::bail!(
"MCP server '{name}' already exists in {scope} scope. Remove it first with: rkat mcp remove {name} --scope {scope}"
);
}
let server = build_server_config(name.clone(), transport, url, headers, command, env)?;
let path = match scope {
McpScope::User => {
user_mcp_path().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?
}
McpScope::Project => project_mcp_path()
.ok_or_else(|| anyhow::anyhow!("Could not determine project directory"))?,
};
{
let path = path.clone();
let server = server.clone();
tokio::task::spawn_blocking(move || add_server_to_file(&path, &server))
.await
.map_err(|e| anyhow::anyhow!("Failed to update mcp.toml: {e}"))??;
}
let (kind, target) = format_server_target(&server);
println!(
"Added {} MCP server '{}' ({}) to {} config: {}",
transport_label(kind),
name,
target,
scope,
path.display()
);
Ok(())
}
fn parse_env_vars(env: &[String]) -> anyhow::Result<HashMap<String, String>> {
let mut env_map = HashMap::new();
for e in env {
let parts: Vec<&str> = e.splitn(2, '=').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid environment variable format: '{e}'. Expected KEY=VALUE");
}
env_map.insert(parts[0].to_string(), parts[1].to_string());
}
Ok(env_map)
}
fn parse_headers(headers: &[String]) -> anyhow::Result<HashMap<String, String>> {
let mut header_map = HashMap::new();
for h in headers {
let parts: Vec<&str> = h.splitn(2, ':').collect();
if parts.len() != 2 {
anyhow::bail!("Invalid header format: '{h}'. Expected KEY:VALUE");
}
header_map.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
}
Ok(header_map)
}
pub async fn remove_server(name: String, scope: Option<McpScope>) -> anyhow::Result<()> {
let scopes = McpConfig::find_server_scopes(&name).await?;
if scopes.is_empty() {
anyhow::bail!("MCP server '{name}' not found");
}
let target_scope = match scope {
Some(s) => {
if !scopes.contains(&s) {
anyhow::bail!("MCP server '{name}' not found in {s} scope");
}
s
}
None => {
if scopes.len() > 1 {
anyhow::bail!(
"MCP server '{}' exists in multiple scopes: {:?}. Specify --scope to remove from a specific scope.",
name,
scopes
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
);
}
scopes[0]
}
};
let path = match target_scope {
McpScope::User => {
user_mcp_path().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?
}
McpScope::Project => {
find_project_mcp().ok_or_else(|| anyhow::anyhow!("No project mcp.toml found"))?
}
};
{
let path = path.clone();
let name = name.clone();
tokio::task::spawn_blocking(move || remove_server_from_file(&path, &name))
.await
.map_err(|e| anyhow::anyhow!("Failed to update mcp.toml: {e}"))??;
}
println!(
"Removed MCP server '{}' from {} config: {}",
name,
target_scope,
path.display()
);
Ok(())
}
pub async fn list_servers(scope: Option<McpScope>, json_output: bool) -> anyhow::Result<()> {
let servers = match scope {
Some(s) => {
let config = McpConfig::load_scope(s).await?;
config
.servers
.into_iter()
.map(|server| meerkat_core::mcp_config::McpServerWithScope { server, scope: s })
.collect()
}
None => McpConfig::load_with_scopes().await?,
};
if json_output {
let json: Vec<serde_json::Value> = servers
.iter()
.map(|s| match &s.server.transport {
McpTransportConfig::Stdio(stdio) => serde_json::json!({
"name": s.server.name,
"transport": "stdio",
"command": stdio.command,
"args": stdio.args,
"env": stdio.env,
"scope": s.scope.to_string(),
}),
McpTransportConfig::Http(http) => serde_json::json!({
"name": s.server.name,
"transport": match s.server.transport_kind() {
McpTransportKind::Sse => "sse",
_ => "streamable-http",
},
"url": http.url,
"headers": http.headers,
"scope": s.scope.to_string(),
}),
})
.collect();
println!("{}", serde_json::to_string_pretty(&json)?);
} else {
if servers.is_empty() {
println!("No MCP servers configured.");
println!("\nAdd a server with: rkat mcp add <name> -- <command> [args...]");
return Ok(());
}
println!("{:<20} {:<10} {:<16} TARGET", "NAME", "SCOPE", "TRANSPORT");
println!("{}", "-".repeat(60));
for s in &servers {
let (kind, target) = format_server_target(&s.server);
let cmd_display = truncate_str(&target, 40);
println!(
"{:<20} {:<10} {:<16} {}",
s.server.name,
s.scope,
transport_label(kind),
cmd_display
);
}
}
Ok(())
}
pub async fn get_server(
name: String,
scope: Option<McpScope>,
json_output: bool,
) -> anyhow::Result<()> {
let servers = match scope {
Some(s) => {
let config = McpConfig::load_scope(s).await?;
config
.servers
.into_iter()
.filter(|server| server.name == name)
.map(|server| meerkat_core::mcp_config::McpServerWithScope { server, scope: s })
.collect::<Vec<_>>()
}
None => McpConfig::load_with_scopes()
.await?
.into_iter()
.filter(|s| s.server.name == name)
.collect(),
};
if servers.is_empty() {
anyhow::bail!("MCP server '{name}' not found");
}
let server = &servers[0];
if json_output {
let json = match &server.server.transport {
McpTransportConfig::Stdio(stdio) => serde_json::json!({
"name": server.server.name,
"transport": "stdio",
"command": stdio.command,
"args": stdio.args,
"env": stdio.env,
"scope": server.scope.to_string(),
}),
McpTransportConfig::Http(http) => serde_json::json!({
"name": server.server.name,
"transport": match server.server.transport_kind() {
McpTransportKind::Sse => "sse",
_ => "streamable-http",
},
"url": http.url,
"headers": http.headers,
"scope": server.scope.to_string(),
}),
};
println!("{}", serde_json::to_string_pretty(&json)?);
} else {
println!("Name: {}", server.server.name);
println!("Scope: {}", server.scope);
match &server.server.transport {
McpTransportConfig::Stdio(stdio) => {
println!("Transport: stdio");
println!("Command: {}", stdio.command);
if !stdio.args.is_empty() {
println!("Args: {}", stdio.args.join(" "));
}
if !stdio.env.is_empty() {
println!("Env:");
for (k, v) in &stdio.env {
let display_value = if k.to_lowercase().contains("key")
|| k.to_lowercase().contains("secret")
|| k.to_lowercase().contains("token")
|| k.to_lowercase().contains("password")
{
mask_secret(v)
} else {
v.clone()
};
println!(" {k}={display_value}");
}
}
}
McpTransportConfig::Http(http) => {
let transport = match server.server.transport_kind() {
McpTransportKind::Sse => "sse",
_ => "streamable-http",
};
println!("Transport: {transport}");
println!("URL: {}", http.url);
if !http.headers.is_empty() {
println!("Headers:");
for (k, v) in &http.headers {
let display_value = if k.to_lowercase().contains("key")
|| k.to_lowercase().contains("secret")
|| k.to_lowercase().contains("token")
|| k.to_lowercase().contains("password")
{
mask_secret(v)
} else {
v.clone()
};
println!(" {k}: {display_value}");
}
}
}
}
}
Ok(())
}
fn add_server_to_file(path: &Path, server: &McpServerConfig) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut doc = if path.exists() {
let contents = fs::read_to_string(path)?;
contents.parse::<DocumentMut>()?
} else {
DocumentMut::new()
};
if !doc.contains_key("servers") {
doc["servers"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new());
}
let servers = doc["servers"]
.as_array_of_tables_mut()
.ok_or_else(|| anyhow::anyhow!("Invalid mcp.toml: 'servers' is not an array of tables"))?;
if servers
.iter()
.any(|t| t.get("name").and_then(|v| v.as_str()) == Some(&server.name))
{
anyhow::bail!("MCP server '{}' already exists in this file", server.name);
}
let mut table = Table::new();
table["name"] = toml_edit::value(&server.name);
match &server.transport {
McpTransportConfig::Stdio(stdio) => {
table["command"] = toml_edit::value(&stdio.command);
if !stdio.args.is_empty() {
let mut args = Array::new();
for arg in &stdio.args {
args.push(arg.as_str());
}
table["args"] = toml_edit::value(args);
}
if !stdio.env.is_empty() {
let mut env_table = toml_edit::InlineTable::new();
for (k, v) in &stdio.env {
env_table.insert(k, v.as_str().into());
}
table["env"] = toml_edit::value(env_table);
}
}
McpTransportConfig::Http(http) => {
table["url"] = toml_edit::value(&http.url);
if !http.headers.is_empty() {
let mut header_table = toml_edit::InlineTable::new();
for (k, v) in &http.headers {
header_table.insert(k, v.as_str().into());
}
table["headers"] = toml_edit::value(header_table);
}
if matches!(server.transport_kind(), McpTransportKind::Sse) {
table["transport"] = toml_edit::value("sse");
}
}
}
servers.push(table);
fs::write(path, doc.to_string())?;
Ok(())
}
fn remove_server_from_file(path: &Path, name: &str) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!("Config file does not exist: {}", path.display());
}
let contents = fs::read_to_string(path)?;
let mut doc = contents.parse::<DocumentMut>()?;
let servers = doc["servers"]
.as_array_of_tables_mut()
.ok_or_else(|| anyhow::anyhow!("Invalid mcp.toml: 'servers' is not an array of tables"))?;
let initial_len = servers.len();
servers.retain(|t| t.get("name").and_then(|v| v.as_str()) != Some(name));
if servers.len() == initial_len {
anyhow::bail!("MCP server '{}' not found in {}", name, path.display());
}
fs::write(path, doc.to_string())?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_server(name: &str, cmd: &str, args: Vec<&str>) -> McpServerConfig {
McpServerConfig::stdio(
name,
cmd,
args.into_iter()
.map(std::string::ToString::to_string)
.collect(),
HashMap::new(),
)
}
#[test]
fn test_add_server_to_new_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
let server = create_test_server("test-server", "npx", vec!["-y", "@test/server"]);
add_server_to_file(&file, &server).unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains("[[servers]]"));
assert!(contents.contains(r#"name = "test-server""#));
assert!(contents.contains(r#"command = "npx""#));
assert!(contents.contains(r#"args = ["-y", "@test/server"]"#));
}
#[test]
fn test_add_server_to_existing_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
fs::write(
&file,
r#"# My MCP config
[[servers]]
name = "existing"
command = "existing-cmd"
"#,
)
.unwrap();
let server = create_test_server("new-server", "new-cmd", vec![]);
add_server_to_file(&file, &server).unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains("# My MCP config"));
assert!(contents.contains(r#"name = "existing""#));
assert!(contents.contains(r#"name = "new-server""#));
}
#[test]
fn test_add_server_with_env() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
let mut env = HashMap::new();
env.insert("API_KEY".to_string(), "secret123".to_string());
let server = McpServerConfig::stdio("env-server", "cmd", vec![], env);
add_server_to_file(&file, &server).unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains(r#"env = { API_KEY = "secret123" }"#));
}
#[test]
fn test_add_duplicate_server_fails() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
let server = create_test_server("dup-server", "cmd", vec![]);
add_server_to_file(&file, &server).unwrap();
let result = add_server_to_file(&file, &server);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn test_remove_server_from_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
fs::write(
&file,
r#"[[servers]]
name = "keep-me"
command = "keep"
[[servers]]
name = "remove-me"
command = "remove"
"#,
)
.unwrap();
remove_server_from_file(&file, "remove-me").unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains(r#"name = "keep-me""#));
assert!(!contents.contains(r#"name = "remove-me""#));
}
#[test]
fn test_remove_nonexistent_server_fails() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
fs::write(
&file,
r#"[[servers]]
name = "only-server"
command = "cmd"
"#,
)
.unwrap();
let result = remove_server_from_file(&file, "nonexistent");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_remove_from_missing_file_fails() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("nonexistent.toml");
let result = remove_server_from_file(&file, "any");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not exist"));
}
#[test]
fn test_truncate_str_ascii() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 8), "hello...");
assert_eq!(truncate_str("hi", 2), "hi");
}
#[test]
fn test_truncate_str_unicode() {
assert_eq!(truncate_str("🎉🎊🎈🎁", 3), "...");
assert_eq!(truncate_str("hello 世界", 8), "hello 世界");
assert_eq!(truncate_str("hello 世界!", 8), "hello...");
}
#[test]
fn test_mask_secret_short() {
assert_eq!(mask_secret("abc"), "****");
assert_eq!(mask_secret("abcd"), "****");
}
#[test]
fn test_mask_secret_long() {
assert_eq!(mask_secret("secret123"), "se...23");
assert_eq!(mask_secret("my-api-key"), "my...ey");
}
#[test]
fn test_mask_secret_unicode() {
assert_eq!(mask_secret("密码很长的"), "密码...长的");
}
#[test]
fn test_add_http_server_to_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
let server = McpServerConfig::streamable_http(
"http-server",
"https://api.example.com/mcp",
HashMap::new(),
);
add_server_to_file(&file, &server).unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains("[[servers]]"));
assert!(contents.contains(r#"name = "http-server""#));
assert!(contents.contains(r#"url = "https://api.example.com/mcp""#));
assert!(!contents.contains("command")); }
#[test]
fn test_add_sse_server_to_file() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
let server =
McpServerConfig::sse("sse-server", "https://api.example.com/sse", HashMap::new());
add_server_to_file(&file, &server).unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains("[[servers]]"));
assert!(contents.contains(r#"name = "sse-server""#));
assert!(contents.contains(r#"url = "https://api.example.com/sse""#));
assert!(contents.contains(r#"transport = "sse""#));
}
#[test]
fn test_add_http_server_with_headers() {
let temp = TempDir::new().unwrap();
let file = temp.path().join("mcp.toml");
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer token123".to_string());
headers.insert("X-Custom".to_string(), "value".to_string());
let server =
McpServerConfig::streamable_http("auth-server", "https://api.example.com/mcp", headers);
add_server_to_file(&file, &server).unwrap();
let contents = fs::read_to_string(&file).unwrap();
assert!(contents.contains(r#"name = "auth-server""#));
assert!(contents.contains("headers"));
assert!(contents.contains("Authorization"));
assert!(contents.contains("Bearer token123"));
}
#[test]
fn test_parse_headers_valid() {
let headers = vec![
"Content-Type:application/json".to_string(),
"Auth: Bearer xyz".to_string(),
];
let result = parse_headers(&headers).unwrap();
assert_eq!(
result.get("Content-Type"),
Some(&"application/json".to_string())
);
assert_eq!(result.get("Auth"), Some(&"Bearer xyz".to_string()));
}
#[test]
fn test_parse_headers_invalid() {
let headers = vec!["InvalidHeader".to_string()];
let result = parse_headers(&headers);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid header format")
);
}
#[test]
fn test_parse_env_vars_valid() {
let env = vec!["KEY=value".to_string(), "FOO=bar=baz".to_string()];
let result = parse_env_vars(&env).unwrap();
assert_eq!(result.get("KEY"), Some(&"value".to_string()));
assert_eq!(result.get("FOO"), Some(&"bar=baz".to_string())); }
#[test]
fn test_parse_env_vars_invalid() {
let env = vec!["INVALID".to_string()];
let result = parse_env_vars(&env);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid environment variable")
);
}
#[test]
fn test_format_server_target_stdio() {
let server = McpServerConfig::stdio(
"test",
"npx",
vec!["-y".to_string(), "@test/server".to_string()],
HashMap::new(),
);
let (kind, target) = format_server_target(&server);
assert_eq!(kind, McpTransportKind::Stdio);
assert_eq!(target, "npx -y @test/server");
}
#[test]
fn test_format_server_target_http() {
let server =
McpServerConfig::streamable_http("test", "https://api.example.com", HashMap::new());
let (kind, target) = format_server_target(&server);
assert_eq!(kind, McpTransportKind::StreamableHttp);
assert_eq!(target, "https://api.example.com");
}
#[test]
fn test_format_server_target_sse() {
let server = McpServerConfig::sse("test", "https://api.example.com/sse", HashMap::new());
let (kind, target) = format_server_target(&server);
assert_eq!(kind, McpTransportKind::Sse);
assert_eq!(target, "https://api.example.com/sse");
}
#[test]
fn test_transport_label() {
assert_eq!(transport_label(McpTransportKind::Stdio), "stdio");
assert_eq!(
transport_label(McpTransportKind::StreamableHttp),
"streamable-http"
);
assert_eq!(transport_label(McpTransportKind::Sse), "sse");
}
}