use serde::{Deserialize, Serialize};
use serde_json::json;
use std::env;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum McpClient {
Claude,
Cursor,
}
impl McpClient {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"claude" | "claude-desktop" | "claude_desktop" => Some(Self::Claude),
"cursor" => Some(Self::Cursor),
_ => None,
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::Claude => "Claude Desktop",
Self::Cursor => "Cursor",
}
}
}
#[derive(Debug)]
pub struct ConfigDetection {
pub config_path: PathBuf,
pub exists: bool,
}
#[derive(Debug, Serialize)]
pub struct GeneratedConfig {
pub server_name: String,
pub config: McpServerEntry,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerEntry {
pub command: String,
pub args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
}
pub fn detect_config_path(client: McpClient) -> Option<PathBuf> {
match client {
McpClient::Claude => detect_claude_config_path(),
McpClient::Cursor => detect_cursor_config_path(),
}
}
fn detect_claude_config_path() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
dirs::home_dir()
.map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json"))
}
#[cfg(target_os = "windows")]
{
dirs::data_dir().map(|d| d.join("Claude/claude_desktop_config.json"))
}
#[cfg(target_os = "linux")]
{
dirs::config_dir().map(|c| c.join("Claude/claude_desktop_config.json"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
None
}
}
fn detect_cursor_config_path() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
dirs::home_dir().map(|h| {
h.join("Library/Application Support/Cursor/User/globalStorage/cursor.mcp/config.json")
})
}
#[cfg(target_os = "windows")]
{
dirs::data_dir().map(|d| d.join("Cursor/User/globalStorage/cursor.mcp/config.json"))
}
#[cfg(target_os = "linux")]
{
dirs::config_dir().map(|c| c.join("Cursor/User/globalStorage/cursor.mcp/config.json"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
None
}
}
pub fn detect_config(client: McpClient) -> Option<ConfigDetection> {
let config_path = detect_config_path(client)?;
let exists = config_path.exists();
Some(ConfigDetection {
config_path,
exists,
})
}
pub fn generate_assay_config(
server_name: &str,
policy_path: &str,
wrapped_command: &str,
wrapped_args: &[String],
assay_binary: Option<&str>,
) -> GeneratedConfig {
let assay_cmd = assay_binary
.map(String::from)
.or_else(detect_assay_binary)
.unwrap_or_else(|| "assay".to_string());
let mut args = vec![
"mcp".to_string(),
"wrap".to_string(),
"--policy".to_string(),
policy_path.to_string(),
"--".to_string(),
wrapped_command.to_string(),
];
args.extend(wrapped_args.iter().cloned());
GeneratedConfig {
server_name: server_name.to_string(),
config: McpServerEntry {
command: assay_cmd,
args,
env: None,
},
}
}
pub fn generate_filesystem_config(
policy_path: &str,
allowed_directory: &str,
assay_binary: Option<&str>,
) -> GeneratedConfig {
generate_assay_config(
"filesystem-secure",
policy_path,
"npx",
&[
"-y".to_string(),
"@modelcontextprotocol/server-filesystem".to_string(),
allowed_directory.to_string(),
],
assay_binary,
)
}
fn detect_assay_binary() -> Option<String> {
if let Ok(exe) = env::current_exe() {
if exe
.file_name()
.map(|n| n.to_string_lossy().contains("assay"))
.unwrap_or(false)
{
return Some(exe.to_string_lossy().to_string());
}
}
let candidates = [
dirs::home_dir().map(|h| h.join(".cargo/bin/assay")),
dirs::home_dir().map(|h| h.join(".local/bin/assay")),
Some(PathBuf::from("/usr/local/bin/assay")),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
return Some(candidate.to_string_lossy().to_string());
}
}
None
}
pub fn default_policy_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("assay/policy.yaml")
}
pub fn format_as_mcp_servers_entry(config: &GeneratedConfig) -> String {
let entry = json!({
&config.server_name: &config.config
});
serde_json::to_string_pretty(&entry).unwrap_or_else(|_| "{}".to_string())
}
pub fn generate_cli_output(
client: McpClient,
policy_path: Option<&str>,
wrapped_server: Option<(&str, &[String])>,
) -> String {
let detection = detect_config(client);
let policy = policy_path
.map(String::from)
.unwrap_or_else(|| default_policy_path().to_string_lossy().to_string());
let home_dir = dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|| "~".to_string());
let config = if let Some((cmd, args)) = wrapped_server {
generate_assay_config("mcp-secure", &policy, cmd, args, None)
} else {
generate_filesystem_config(&policy, &home_dir, None)
};
let mut output = String::new();
output.push_str(&format!("┌─ {} Configuration\n", client.display_name()));
output.push_str("│\n");
if let Some(ref det) = detection {
output.push_str(&format!("│ Config file: {}\n", det.config_path.display()));
if det.exists {
output.push_str("│ Status: ✓ Found\n");
} else {
output.push_str("│ Status: ✗ Not found (will be created)\n");
}
} else {
output.push_str("│ Config file: Could not detect path\n");
output.push_str("│ Status: ✗ Unknown OS or client not installed\n");
}
output.push_str("│\n");
output.push_str("├─ Policy file\n");
output.push_str("│\n");
output.push_str(&format!("│ {}\n", policy));
output.push_str("│\n");
output.push_str("├─ Add this to your mcpServers:\n");
output.push_str("│\n");
let json_snippet = format_as_mcp_servers_entry(&config);
for line in json_snippet.lines() {
output.push_str(&format!("│ {}\n", line));
}
output.push_str("│\n");
output.push_str("└─ Next steps:\n");
output.push_str(" 1. Create your policy file\n");
output.push_str(" 2. Add the above JSON to your config file\n");
output.push_str(&format!(" 3. Restart {}\n", client.display_name()));
output
}
pub fn run(args: crate::cli::args::ConfigPathArgs) {
let client = match McpClient::from_str(&args.client) {
Some(c) => c,
None => {
eprintln!(
"Error: Unknown client '{}'. Supported: claude, cursor",
args.client
);
std::process::exit(1);
}
};
let wrapped_tuple = args.server.as_deref().and_then(|server_cmd| {
let mut parts = server_cmd.split_whitespace();
let cmd = parts.next()?;
let args: Vec<String> = parts.map(String::from).collect();
Some((cmd.to_string(), args))
});
let (server_cmd_owned, server_args_owned) = match wrapped_tuple {
Some((cmd, args)) => (Some(cmd), args),
None => (None, vec![]),
};
let wrapped_tuple_ref = server_cmd_owned
.as_deref()
.map(|cmd| (cmd, server_args_owned.as_slice()));
if args.json {
let detection = detect_config(client);
let policy = args
.policy
.clone()
.unwrap_or_else(|| default_policy_path().to_string_lossy().to_string());
let home_dir = dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|| "~".to_string());
let config = if let Some((cmd, args)) = wrapped_tuple_ref {
generate_assay_config("mcp-secure", &policy, cmd, args, None)
} else {
generate_filesystem_config(&policy, &home_dir, None)
};
let output = json!({
"client": client.display_name(),
"config_path": detection.as_ref().map(|d| d.config_path.clone()),
"config_exists": detection.as_ref().map(|d| d.exists).unwrap_or(false),
"generated_server": config
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
} else {
println!(
"{}",
generate_cli_output(client, args.policy.as_deref(), wrapped_tuple_ref)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_from_str() {
assert_eq!(McpClient::from_str("claude"), Some(McpClient::Claude));
assert_eq!(McpClient::from_str("Claude"), Some(McpClient::Claude));
assert_eq!(
McpClient::from_str("claude-desktop"),
Some(McpClient::Claude)
);
assert_eq!(McpClient::from_str("cursor"), Some(McpClient::Cursor));
assert_eq!(McpClient::from_str("vscode"), None);
}
#[test]
fn test_generate_filesystem_config() {
let config = generate_filesystem_config(
"/home/user/.config/assay/policy.yaml",
"/home/user",
Some("/usr/local/bin/assay"),
);
assert_eq!(config.server_name, "filesystem-secure");
assert_eq!(config.config.command, "/usr/local/bin/assay");
assert!(config.config.args.contains(&"mcp".to_string()));
assert!(config.config.args.contains(&"wrap".to_string()));
assert!(config.config.args.contains(&"--policy".to_string()));
}
#[test]
fn test_format_as_mcp_servers_entry() {
let config = GeneratedConfig {
server_name: "test-server".to_string(),
config: McpServerEntry {
command: "assay".to_string(),
args: vec!["mcp".to_string(), "wrap".to_string()],
env: None,
},
};
let output = format_as_mcp_servers_entry(&config);
assert!(output.contains("test-server"));
assert!(output.contains("assay"));
}
}