use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientDialect {
StandardMcpServers,
ZedContextServers,
OpenCodeMcp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ClientTransport {
#[default]
Stdio,
Http,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientAppDef {
pub id: String,
pub name: String,
pub category: String,
pub dialect: ClientDialect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientAppStatus {
pub id: String,
pub name: String,
pub category: String,
pub config_path: String,
pub config_exists: bool,
pub app_installed: bool,
pub is_attached: bool,
pub attached_profile: Option<String>,
pub other_servers_count: usize,
pub attached_transport: Option<ClientTransport>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AttachOptions {
pub profile: Option<String>,
pub config_path: Option<String>,
pub binary_path: Option<String>,
#[serde(default)]
pub transport: ClientTransport,
pub http_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttachResult {
pub client_id: String,
pub config_path: String,
pub backup_path: Option<String>,
pub ok: bool,
pub message: String,
pub transport: ClientTransport,
pub http_url: Option<String>,
}
impl ClientAppDef {
pub fn supports_http(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetachResult {
pub client_id: String,
pub config_path: String,
pub was_attached: bool,
pub ok: bool,
pub message: String,
}
pub fn get_supported_clients() -> Vec<ClientAppDef> {
vec![
ClientAppDef {
id: "claude-desktop".to_string(),
name: "Claude Desktop".to_string(),
category: "Desktop App".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "opencode".to_string(),
name: "OpenCode".to_string(),
category: "AI Terminal / Agent".to_string(),
dialect: ClientDialect::OpenCodeMcp,
},
ClientAppDef {
id: "claude-code".to_string(),
name: "Claude Code CLI".to_string(),
category: "CLI Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "cursor".to_string(),
name: "Cursor (Global)".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "zed".to_string(),
name: "Zed Editor".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::ZedContextServers,
},
ClientAppDef {
id: "windsurf".to_string(),
name: "Windsurf".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "cline".to_string(),
name: "Roo Code / Cline".to_string(),
category: "VS Code Extension".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "antigravity".to_string(),
name: "Google Antigravity (AGY)".to_string(),
category: "IDE / Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "codex".to_string(),
name: "OpenAI Codex CLI".to_string(),
category: "CLI Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "gemini-cli".to_string(),
name: "Gemini CLI".to_string(),
category: "CLI Tool".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "continue".to_string(),
name: "Continue.dev".to_string(),
category: "IDE Extension".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "vscode".to_string(),
name: "VS Code Copilot MCP".to_string(),
category: "IDE".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "goose".to_string(),
name: "Goose (Block)".to_string(),
category: "Desktop / CLI Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "librechat".to_string(),
name: "LibreChat".to_string(),
category: "Agent Platform".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "deepseek".to_string(),
name: "DeepSeek Harness".to_string(),
category: "Agent Framework".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "cody".to_string(),
name: "Sourcegraph Cody".to_string(),
category: "IDE Extension".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
ClientAppDef {
id: "devin".to_string(),
name: "Devin CLI".to_string(),
category: "Autonomous Agent".to_string(),
dialect: ClientDialect::StandardMcpServers,
},
]
}
pub fn resolve_client_config_path(client_id: &str) -> Option<PathBuf> {
let home = std::env::var("HOME").ok().map(PathBuf::from);
let _appdata = std::env::var("APPDATA").ok().map(PathBuf::from);
let _userprofile = std::env::var("USERPROFILE").ok().map(PathBuf::from);
match client_id {
"claude-desktop" => {
#[cfg(target_os = "macos")]
if let Some(ref h) = home {
return Some(
h.join("Library/Application Support/Claude/claude_desktop_config.json"),
);
}
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("Claude/claude_desktop_config.json"));
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
if let Some(ref h) = home {
return Some(h.join(".config/Claude/claude_desktop_config.json"));
}
None
}
"opencode" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("opencode/opencode.json"));
}
#[cfg(not(target_os = "windows"))]
if let Some(ref h) = home {
return Some(h.join(".config/opencode/opencode.json"));
}
None
}
"claude-code" => {
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let dir_path = PathBuf::from(config_dir.trim());
let cand_json = dir_path.join("claude.json");
let cand_dot = dir_path.join(".claude.json");
if cand_json.exists() {
return Some(cand_json);
}
if cand_dot.exists() {
return Some(cand_dot);
}
return Some(cand_dot);
}
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".claude.json"));
}
if let Some(ref h) = home {
return Some(h.join(".claude.json"));
}
None
}
"cursor" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".cursor/mcp.json"));
}
if let Some(ref h) = home {
return Some(h.join(".cursor/mcp.json"));
}
None
}
"zed" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("Zed/settings.json"));
}
if let Some(ref h) = home {
return Some(h.join(".config/zed/settings.json"));
}
None
}
"windsurf" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".codeium/windsurf/mcp_config.json"));
}
if let Some(ref h) = home {
return Some(h.join(".codeium/windsurf/mcp_config.json"));
}
None
}
"cline" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".cline/data/settings/cline_mcp_settings.json"));
}
if let Some(ref h) = home {
return Some(h.join(".cline/data/settings/cline_mcp_settings.json"));
}
None
}
"antigravity" => {
if let Some(ref h) = home {
let p = h.join(".gemini/config/mcp_config.json");
if p.exists() {
return Some(p);
}
return Some(h.join(".gemini/config/mcp_config.json"));
}
None
}
"codex" => {
if let Some(ref h) = home {
return Some(h.join(".codex/mcp_servers.json"));
}
None
}
"gemini-cli" => {
#[cfg(target_os = "windows")]
if let Some(ref up) = userprofile {
return Some(up.join(".gemini/settings.json"));
}
if let Some(ref h) = home {
return Some(h.join(".gemini/settings.json"));
}
None
}
"continue" => {
if let Some(ref h) = home {
return Some(h.join(".continue/config.json"));
}
None
}
"vscode" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(
ad.join("Code/User/globalStorage/github.copilot-chat/mcp-config.json"),
);
}
#[cfg(target_os = "macos")]
if let Some(ref h) = home {
return Some(h.join("Library/Application Support/Code/User/globalStorage/github.copilot-chat/mcp-config.json"));
}
if let Some(ref h) = home {
return Some(
h.join(".config/Code/User/globalStorage/github.copilot-chat/mcp-config.json"),
);
}
None
}
"goose" => {
#[cfg(target_os = "windows")]
if let Some(ref ad) = appdata {
return Some(ad.join("Block/goose/config/config.json"));
}
if let Some(ref h) = home {
return Some(h.join(".config/goose/config.json"));
}
None
}
"librechat" => {
if let Some(ref h) = home {
return Some(h.join(".config/librechat/librechat.json"));
}
None
}
"deepseek" => {
if let Some(ref h) = home {
return Some(h.join(".deepseek/mcp.json"));
}
None
}
"cody" => {
if let Some(ref h) = home {
return Some(h.join(".sourcegraph/cody.json"));
}
None
}
"devin" => {
if let Some(ref h) = home {
return Some(h.join(".devin/mcp.json"));
}
None
}
_ => None,
}
}
pub fn detect_clients() -> Vec<ClientAppStatus> {
let clients = get_supported_clients();
let mut statuses = Vec::new();
for client in clients {
let config_path_opt = resolve_client_config_path(&client.id);
let (
config_path_str,
config_exists,
app_installed,
is_attached,
attached_profile,
attached_transport,
other_servers_count,
) = if let Some(ref path) = config_path_opt {
let config_exists = path.exists();
let parent_exists = path.parent().map(|p| p.exists()).unwrap_or(false);
let app_installed = config_exists || parent_exists;
let (is_attached, attached_profile, attached_transport, other_servers_count) =
if config_exists {
inspect_client_config(path, client.dialect)
} else {
(false, None, None, 0)
};
(
path.to_string_lossy().to_string(),
config_exists,
app_installed,
is_attached,
attached_profile,
attached_transport,
other_servers_count,
)
} else {
("Unknown".to_string(), false, false, false, None, None, 0)
};
statuses.push(ClientAppStatus {
id: client.id,
name: client.name,
category: client.category,
config_path: config_path_str,
config_exists,
app_installed,
is_attached,
attached_profile,
attached_transport,
other_servers_count,
});
}
statuses
}
fn inspect_client_config(
path: &Path,
dialect: ClientDialect,
) -> (bool, Option<String>, Option<ClientTransport>, usize) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return (false, None, None, 0),
};
let val: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return (false, None, None, 0),
};
match dialect {
ClientDialect::StandardMcpServers => {
if let Some(servers) = val.get("mcpServers").and_then(Value::as_object) {
let is_attached = servers.contains_key("warmplane");
let profile = if is_attached {
extract_profile_from_server_val(servers.get("warmplane"))
} else {
None
};
let other_count = if is_attached {
servers.len().saturating_sub(1)
} else {
servers.len()
};
(
is_attached,
profile,
servers.get("warmplane").map(detect_transport),
other_count,
)
} else {
(false, None, None, 0)
}
}
ClientDialect::ZedContextServers => {
if let Some(servers) = val.get("context_servers").and_then(Value::as_object) {
let is_attached = servers.contains_key("warmplane");
let profile = if is_attached {
extract_profile_from_server_val(servers.get("warmplane"))
} else {
None
};
let other_count = if is_attached {
servers.len().saturating_sub(1)
} else {
servers.len()
};
(
is_attached,
profile,
servers.get("warmplane").map(detect_transport),
other_count,
)
} else {
(false, None, None, 0)
}
}
ClientDialect::OpenCodeMcp => {
if let Some(servers) = val.get("mcp").and_then(Value::as_object) {
let is_attached = servers.contains_key("warmplane");
let profile = if is_attached {
extract_profile_from_server_val(servers.get("warmplane"))
} else {
None
};
let other_count = if is_attached {
servers.len().saturating_sub(1)
} else {
servers.len()
};
(
is_attached,
profile,
servers.get("warmplane").map(detect_transport),
other_count,
)
} else {
(false, None, None, 0)
}
}
}
}
fn detect_transport(server_val: &Value) -> ClientTransport {
if server_val.get("url").and_then(Value::as_str).is_some()
|| server_val
.get("serverUrl")
.and_then(Value::as_str)
.is_some()
|| server_val.get("type").and_then(Value::as_str) == Some("remote")
{
ClientTransport::Http
} else {
ClientTransport::Stdio
}
}
fn extract_profile_from_server_val(server_val: Option<&Value>) -> Option<String> {
let s = server_val?;
let args_val = if let Some(args) = s.get("args").and_then(Value::as_array) {
Some(args)
} else if let Some(cmd_obj) = s.get("command").and_then(Value::as_object) {
cmd_obj.get("args").and_then(Value::as_array)
} else {
None
};
if let Some(args) = args_val {
let mut iter = args.iter().filter_map(Value::as_str);
while let Some(arg) = iter.next() {
if arg == "--profile" {
return iter.next().map(ToString::to_string);
}
}
}
None
}
pub fn attach_client(client_id: &str, options: &AttachOptions) -> Result<AttachResult> {
let clients = get_supported_clients();
let client_def = clients
.into_iter()
.find(|c| c.id == client_id)
.with_context(|| format!("Unsupported client ID: {}", client_id))?;
let config_path = resolve_client_config_path(client_id)
.with_context(|| format!("Could not resolve configuration path for {}", client_id))?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let mut backup_path = None;
let mut root_val: Value = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let bak = config_path.with_extension(format!("bak.{}", now));
let _ = std::fs::copy(&config_path, &bak);
backup_path = Some(bak.to_string_lossy().to_string());
serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
if !root_val.is_object() {
root_val = json!({});
}
let binary = options
.binary_path
.clone()
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(ToString::to_string))
})
.unwrap_or_else(|| "warmplane".to_string());
let warmplane_config = options
.config_path
.clone()
.unwrap_or_else(|| "mcp_servers.json".to_string());
let warmplane_config_abs = std::fs::canonicalize(&warmplane_config)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or(warmplane_config.clone());
if options.transport == ClientTransport::Http && !client_def.supports_http() {
anyhow::bail!("{} does not support HTTP MCP connections", client_def.name);
}
let http_url = if options.transport == ClientTransport::Http {
let configured_http = crate::config::load_or_default_config(&warmplane_config)
.ok()
.and_then(|c| c.mcp_http_server);
if configured_http.is_none() && options.http_url.is_none() {
anyhow::bail!(
"HTTP attachment requires mcpHttpServer in the Warmplane config or an explicit URL"
);
}
Some(options.http_url.clone().unwrap_or_else(|| {
let http = configured_http;
let (host, port) = http
.map(|c| {
let host = if c.bind == "0.0.0.0" || c.bind == "::" {
"127.0.0.1".to_string()
} else {
c.bind
};
(host, c.port)
})
.unwrap_or_else(|| {
(
"127.0.0.1".to_string(),
crate::config::DEFAULT_MCP_HTTP_PORT,
)
});
format!("http://{}:{}/mcp", host, port)
}))
} else {
None
};
let mut args = vec![
"mcp-server".to_string(),
"--config".to_string(),
warmplane_config_abs,
];
if let Some(ref prof) = options.profile {
args.push("--profile".to_string());
args.push(prof.clone());
}
match client_def.dialect {
ClientDialect::StandardMcpServers => {
let mcp_servers = root_val
.as_object_mut()
.unwrap()
.entry("mcpServers")
.or_insert_with(|| json!({}));
if !mcp_servers.is_object() {
*mcp_servers = json!({});
}
let entry = if let Some(url) = &http_url {
if client_def.id == "antigravity" {
json!({ "serverUrl": url })
} else {
json!({ "url": url })
}
} else {
json!({ "command": binary, "args": args })
};
mcp_servers
.as_object_mut()
.unwrap()
.insert("warmplane".to_string(), entry);
}
ClientDialect::ZedContextServers => {
let context_servers = root_val
.as_object_mut()
.unwrap()
.entry("context_servers")
.or_insert_with(|| json!({}));
if !context_servers.is_object() {
*context_servers = json!({});
}
let entry = if let Some(url) = &http_url {
json!({ "url": url })
} else {
json!({ "command": { "path": binary, "args": args } })
};
context_servers
.as_object_mut()
.unwrap()
.insert("warmplane".to_string(), entry);
}
ClientDialect::OpenCodeMcp => {
let mcp = root_val
.as_object_mut()
.unwrap()
.entry("mcp")
.or_insert_with(|| json!({}));
if !mcp.is_object() {
*mcp = json!({});
}
let entry = if let Some(url) = &http_url {
json!({ "type": "remote", "url": url, "enabled": true })
} else {
json!({ "type": "local", "command": binary, "args": args, "enabled": true })
};
mcp.as_object_mut()
.unwrap()
.insert("warmplane".to_string(), entry);
}
}
let formatted_json = serde_json::to_string_pretty(&root_val)?;
let temp_path = config_path.with_extension("tmp");
std::fs::write(&temp_path, &formatted_json)
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
std::fs::rename(&temp_path, &config_path)
.with_context(|| format!("Failed to atomically rename to {}", config_path.display()))?;
Ok(AttachResult {
client_id: client_id.to_string(),
config_path: config_path.to_string_lossy().to_string(),
backup_path,
ok: true,
message: format!(
"Successfully attached Warmplane to {} ({})",
client_def.name,
config_path.display()
),
transport: options.transport,
http_url,
})
}
pub fn detach_client(client_id: &str) -> Result<DetachResult> {
let clients = get_supported_clients();
let client_def = clients
.into_iter()
.find(|c| c.id == client_id)
.with_context(|| format!("Unsupported client ID: {}", client_id))?;
let config_path = resolve_client_config_path(client_id)
.with_context(|| format!("Could not resolve configuration path for {}", client_id))?;
if !config_path.exists() {
return Ok(DetachResult {
client_id: client_id.to_string(),
config_path: config_path.to_string_lossy().to_string(),
was_attached: false,
ok: true,
message: format!(
"Configuration file {} does not exist",
config_path.display()
),
});
}
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let mut root_val: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON in {}", config_path.display()))?;
let mut was_attached = false;
match client_def.dialect {
ClientDialect::StandardMcpServers => {
if let Some(servers) = root_val
.get_mut("mcpServers")
.and_then(Value::as_object_mut)
{
was_attached = servers.remove("warmplane").is_some();
}
}
ClientDialect::ZedContextServers => {
if let Some(servers) = root_val
.get_mut("context_servers")
.and_then(Value::as_object_mut)
{
was_attached = servers.remove("warmplane").is_some();
}
}
ClientDialect::OpenCodeMcp => {
if let Some(servers) = root_val.get_mut("mcp").and_then(Value::as_object_mut) {
was_attached = servers.remove("warmplane").is_some();
}
}
}
if was_attached {
let formatted_json = serde_json::to_string_pretty(&root_val)?;
let temp_path = config_path.with_extension("tmp");
std::fs::write(&temp_path, &formatted_json)
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
std::fs::rename(&temp_path, &config_path)
.with_context(|| format!("Failed to atomically rename to {}", config_path.display()))?;
}
Ok(DetachResult {
client_id: client_id.to_string(),
config_path: config_path.to_string_lossy().to_string(),
was_attached,
ok: true,
message: if was_attached {
format!("Successfully detached Warmplane from {}", client_def.name)
} else {
format!("Warmplane was not attached to {}", client_def.name)
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_client_attach_and_detach_standard() {
let temp_dir =
std::env::temp_dir().join(format!("wp_client_test_std_{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
let cfg_file = temp_dir.join("claude_desktop_config.json");
fs::write(
&cfg_file,
r#"{"mcpServers": {"github": {"command": "npx", "args": ["@modelcontextprotocol/server-github"]}}}"#,
)
.unwrap();
let dialect = ClientDialect::StandardMcpServers;
let (attached, prof, _, count) = inspect_client_config(&cfg_file, dialect);
assert!(!attached);
assert_eq!(prof, None);
assert_eq!(count, 1);
let mut val: Value = serde_json::from_str(&fs::read_to_string(&cfg_file).unwrap()).unwrap();
val["mcpServers"]["warmplane"] = json!({
"command": "warmplane",
"args": ["mcp-server", "--config", "/path/mcp_servers.json", "--profile", "coding"]
});
fs::write(&cfg_file, serde_json::to_string_pretty(&val).unwrap()).unwrap();
let (attached2, prof2, _, count2) = inspect_client_config(&cfg_file, dialect);
assert!(attached2);
assert_eq!(prof2.as_deref(), Some("coding"));
assert_eq!(count2, 1);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn test_client_attach_opencode_dialect() {
let temp_dir =
std::env::temp_dir().join(format!("wp_client_test_oc_{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
let cfg_file = temp_dir.join("opencode.json");
fs::write(&cfg_file, r#"{"theme": "dark"}"#).unwrap();
let dialect = ClientDialect::OpenCodeMcp;
let (attached, _, _, _) = inspect_client_config(&cfg_file, dialect);
assert!(!attached);
let mut val: Value = serde_json::from_str(&fs::read_to_string(&cfg_file).unwrap()).unwrap();
val["mcp"] = json!({
"warmplane": {
"type": "local",
"command": "warmplane",
"args": ["mcp-server", "--config", "/path/mcp_servers.json"],
"enabled": true
}
});
fs::write(&cfg_file, serde_json::to_string_pretty(&val).unwrap()).unwrap();
let (attached2, prof2, _, count2) = inspect_client_config(&cfg_file, dialect);
assert!(attached2);
assert_eq!(prof2, None);
assert_eq!(count2, 0);
let _ = fs::remove_dir_all(temp_dir);
}
}