use meerkat_core::mcp_config::{
McpConfig, McpConfigMutationAuthority, McpScope, McpServerConfig, McpTransportConfig,
McpTransportKind,
};
use std::collections::HashMap;
use std::path::Path;
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>,
positional_url: Option<String>,
headers: Vec<String>,
command: Vec<String>,
env: Vec<String>,
) -> anyhow::Result<McpServerConfig> {
if url.is_some() && positional_url.is_some() {
anyhow::bail!("Provide either --url or a positional URL, not both");
}
let url = url.or(positional_url);
if url.is_some() && !command.is_empty() {
anyhow::bail!("Provide either --url or a stdio command after --, not both");
}
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 a URL. Usage: rkat mcp add --transport http <name> <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>\n\
HTTP: rkat mcp add --transport http <name> <url>"
);
}
};
Ok(server)
}
pub struct AddServerRequest {
pub name: String,
pub transport: Option<McpTransportKind>,
pub url: Option<String>,
pub positional_url: Option<String>,
pub headers: Vec<String>,
pub command: Vec<String>,
pub env: Vec<String>,
pub project_scope: bool,
}
pub async fn add_server(
request: AddServerRequest,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> anyhow::Result<()> {
let scope = if request.project_scope {
McpScope::Project
} else {
McpScope::User
};
let name = request.name.clone();
if McpConfig::server_exists_from_roots(&name, scope, context_root, user_config_root).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(
request.name.clone(),
request.transport,
request.url,
request.positional_url,
request.headers,
request.command,
request.env,
)?;
let authority = McpConfigMutationAuthority::for_scope(
scope,
context_root.map(Path::to_path_buf),
user_config_root.map(Path::to_path_buf),
);
let path = authority.resolved_path()?;
McpConfig::persist_add_with_rollback(&authority, server.clone()).await?;
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>,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> anyhow::Result<()> {
let target_scope = match scope {
Some(s) => {
if !McpConfig::server_exists_from_roots(&name, s, context_root, user_config_root)
.await?
{
anyhow::bail!("MCP server '{name}' not found in {s} scope");
}
s
}
None => {
let scopes =
McpConfig::find_server_scopes_from_roots(&name, context_root, user_config_root)
.await?;
if scopes.is_empty() {
anyhow::bail!("MCP server '{name}' not found");
}
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 authority = McpConfigMutationAuthority::for_scope(
target_scope,
context_root.map(Path::to_path_buf),
user_config_root.map(Path::to_path_buf),
);
let path = authority.resolved_path()?;
McpConfig::persist_remove_with_rollback(&authority, &name).await?;
println!(
"Removed MCP server '{}' from {} config: {}",
name,
target_scope,
path.display()
);
Ok(())
}
pub async fn list_servers(
scope: Option<McpScope>,
json_output: bool,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> anyhow::Result<()> {
let servers = match scope {
Some(s) => {
let config =
McpConfig::load_scope_from_roots(s, context_root, user_config_root).await?;
config
.servers
.into_iter()
.map(|server| meerkat_core::mcp_config::McpServerWithScope { server, scope: s })
.collect()
}
None => McpConfig::load_with_scopes_from_roots(context_root, user_config_root).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...]");
println!("Or HTTP: rkat mcp add <name> --url <url>");
println!("Then OAuth login: rkat mcp login <name>");
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,
context_root: Option<&Path>,
user_config_root: Option<&Path>,
) -> anyhow::Result<()> {
let servers = match scope {
Some(s) => {
let config =
McpConfig::load_scope_from_roots(s, context_root, user_config_root).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_from_roots(context_root, user_config_root)
.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(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_offline_mutations_use_explicit_convention_roots() {
let temp = TempDir::new().unwrap();
let context_root = temp.path().join("context");
let user_root = temp.path().join("user");
add_server(
AddServerRequest {
name: "user-server".to_string(),
transport: Some(McpTransportKind::Stdio),
url: None,
positional_url: None,
headers: Vec::new(),
command: vec!["echo".to_string(), "hello".to_string()],
env: Vec::new(),
project_scope: false,
},
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
let user_path = user_root.join(".rkat/mcp.toml");
let project_path = context_root.join(".rkat/mcp.toml");
assert!(user_path.exists());
assert!(!project_path.exists());
remove_server(
"user-server".to_string(),
Some(McpScope::User),
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
let user =
McpConfig::load_scope_from_roots(McpScope::User, Some(&context_root), Some(&user_root))
.await
.unwrap();
assert!(user.servers.is_empty());
}
#[tokio::test]
async fn test_offline_mutations_preserve_operator_owned_toml() {
let temp = TempDir::new().unwrap();
let context_root = temp.path().join("context");
let user_root = temp.path().join("user");
let user_path = user_root.join(".rkat/mcp.toml");
tokio::fs::create_dir_all(user_path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(
&user_path,
r#"# operator note
future_config = "preserve"
[[servers]]
name = "existing"
command = "existing-command"
future_server_key = "preserve-too"
"#,
)
.await
.unwrap();
add_server(
AddServerRequest {
name: "added".to_string(),
transport: Some(McpTransportKind::Stdio),
url: None,
positional_url: None,
headers: Vec::new(),
command: vec!["echo".to_string()],
env: Vec::new(),
project_scope: false,
},
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
remove_server(
"added".to_string(),
Some(McpScope::User),
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
let persisted = tokio::fs::read_to_string(&user_path).await.unwrap();
assert!(persisted.contains("# operator note"));
assert!(persisted.contains("future_config = \"preserve\""));
assert!(persisted.contains("future_server_key = \"preserve-too\""));
assert!(persisted.contains("name = \"existing\""));
assert!(!persisted.contains("name = \"added\""));
}
#[tokio::test]
async fn test_explicit_remove_scope_does_not_parse_unrelated_scope() {
let temp = TempDir::new().unwrap();
let context_root = temp.path().join("context");
let user_root = temp.path().join("user");
tokio::fs::create_dir_all(context_root.join(".rkat"))
.await
.unwrap();
tokio::fs::create_dir_all(user_root.join(".rkat"))
.await
.unwrap();
tokio::fs::write(
context_root.join(".rkat/mcp.toml"),
"[[servers]]\nname = \"target\"\ncommand = \"echo\"\n",
)
.await
.unwrap();
let malformed_user = "servers = \"not-an-array-of-tables\"\n";
tokio::fs::write(user_root.join(".rkat/mcp.toml"), malformed_user)
.await
.unwrap();
remove_server(
"target".to_string(),
Some(McpScope::Project),
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
let project = McpConfig::load_scope_from_roots(
McpScope::Project,
Some(&context_root),
Some(&user_root),
)
.await
.unwrap();
assert!(project.servers.is_empty());
assert_eq!(
tokio::fs::read_to_string(user_root.join(".rkat/mcp.toml"))
.await
.unwrap(),
malformed_user
);
}
#[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_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_build_server_config_rejects_url_and_stdio_command() {
let result = build_server_config(
"mixed".to_string(),
None,
Some("https://mcp.example.com".to_string()),
None,
Vec::new(),
vec!["npx".to_string(), "-y".to_string(), "server".to_string()],
Vec::new(),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("either --url or a stdio command")
);
}
#[test]
fn test_build_server_config_accepts_positional_http_url_without_auth_spec() {
let server = build_server_config(
"glean".to_string(),
Some(McpTransportKind::StreamableHttp),
None,
Some("https://king-be.glean.com/mcp/default".to_string()),
Vec::new(),
Vec::new(),
Vec::new(),
)
.unwrap();
match server.transport {
McpTransportConfig::Http(http) => {
assert_eq!(http.url, "https://king-be.glean.com/mcp/default");
assert!(http.headers.is_empty());
}
_ => panic!("expected http transport"),
}
}
#[test]
fn test_build_server_config_rejects_ambiguous_url_forms() {
let result = build_server_config(
"ambiguous".to_string(),
Some(McpTransportKind::StreamableHttp),
Some("https://one.example/mcp".to_string()),
Some("https://two.example/mcp".to_string()),
Vec::new(),
Vec::new(),
Vec::new(),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("either --url or a positional URL")
);
}
#[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");
}
}