From c84d547a8c606a3c05ee5058d315581e9b8a64af Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Fri, 15 May 2026 19:59:44 +0400 Subject: [PATCH] feat: more mcp integrations --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 132 ++---- src-tauri/src/mcp_integrations.rs | 574 +++++++++++++++++++++++++ src-tauri/src/profile/manager.rs | 40 +- src/components/integrations-dialog.tsx | 340 +++++++++------ src/i18n/locales/en.json | 24 +- src/i18n/locales/es.json | 24 +- src/i18n/locales/fr.json | 24 +- src/i18n/locales/ja.json | 24 +- src/i18n/locales/pt.json | 24 +- src/i18n/locales/ru.json | 24 +- src/i18n/locales/zh.json | 24 +- 13 files changed, 939 insertions(+), 317 deletions(-) create mode 100644 src-tauri/src/mcp_integrations.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c6d0f1c..618a464 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1858,6 +1858,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "tokio-util", + "toml 0.9.12+spec-1.1.0", "tower", "tower-http", "tray-icon 0.24.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e8e1b3..3ee9242 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -100,6 +100,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master tokio-tungstenite = { version = "0.29", features = ["native-tls"] } rusqlite = { version = "0.39", features = ["bundled"] } serde_yaml = "0.9" +toml = "0.9" thiserror = "2.0" regex-lite = "0.1" tempfile = "3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b09e4b..8ba1671 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,6 +52,7 @@ pub mod daemon_client; mod daemon_spawn; pub mod daemon_ws; pub mod events; +mod mcp_integrations; mod mcp_server; mod tag_manager; mod team_lock; @@ -504,20 +505,20 @@ fn claude_desktop_extension_dir() -> Option { } } -#[tauri::command] -fn is_mcp_in_claude_desktop() -> Result { - let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; - Ok(dir.join("manifest.json").exists()) +fn is_mcp_in_claude_desktop_internal() -> bool { + let Some(dir) = claude_desktop_extension_dir() else { + return false; + }; + dir.join("manifest.json").exists() } -#[tauri::command] -async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> { +async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> { let mcp_server = mcp_server::McpServer::instance(); let port = mcp_server.get_port().ok_or("MCP server is not running")?; let settings_manager = settings_manager::SettingsManager::instance(); let token = settings_manager - .get_mcp_token(&app_handle) + .get_mcp_token(app_handle) .await .map_err(|e| format!("Failed to get MCP token: {e}"))? .ok_or("MCP token not found")?; @@ -606,8 +607,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500)); Ok(()) } -#[tauri::command] -fn remove_mcp_from_claude_desktop() -> Result<(), String> { +fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> { let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?; if ext_dir.exists() { std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?; @@ -669,91 +669,48 @@ fn update_claude_extensions_registry( Ok(()) } -fn find_claude_cli() -> Option { - let mut candidates: Vec = vec![ - std::path::PathBuf::from("/usr/local/bin/claude"), - std::path::PathBuf::from("/opt/homebrew/bin/claude"), - ]; - if let Some(home) = dirs::home_dir() { - candidates.insert(0, home.join(".local/bin/claude")); - candidates.push(home.join(".claude/local/claude")); - } - #[cfg(windows)] - if let Ok(appdata) = std::env::var("APPDATA") { - candidates.insert( - 0, - std::path::PathBuf::from(appdata).join("Claude/claude.exe"), - ); - } - for p in &candidates { - if p.exists() { - return Some(p.clone()); - } - } - None -} - -#[tauri::command] -async fn is_mcp_in_claude_code() -> Result { - let cli = find_claude_cli().ok_or("Claude Code CLI not found")?; - // `claude mcp list` health-checks every registered MCP server, so a - // missing or stalled server can hang the call for many seconds. Cap it - // — for this dialog, a slow `claude` is treated the same as "not registered". - let fut = tokio::process::Command::new(&cli) - .args(["mcp", "list"]) - .output(); - let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut) - .await - .map_err(|_| "claude mcp list timed out".to_string())? - .map_err(|e| format!("Failed to run claude: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(stdout.contains("donut-browser")) -} - -#[tauri::command] -async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> { - let cli = find_claude_cli().ok_or("Claude Code CLI not found")?; - +async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result { let mcp_server = mcp_server::McpServer::instance(); let port = mcp_server.get_port().ok_or("MCP server is not running")?; - let settings_manager = settings_manager::SettingsManager::instance(); let token = settings_manager - .get_mcp_token(&app_handle) + .get_mcp_token(app_handle) .await .map_err(|e| format!("Failed to get MCP token: {e}"))? .ok_or("MCP token not found")?; - - let url = format!("http://127.0.0.1:{port}/mcp/{token}"); - - let _ = std::process::Command::new(&cli) - .args(["mcp", "remove", "donut-browser"]) - .output(); - - let output = std::process::Command::new(&cli) - .args(["mcp", "add", "--transport", "http", "donut-browser", &url]) - .output() - .map_err(|e| format!("Failed to run claude: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to add MCP to Claude Code: {stderr}")); - } - Ok(()) + Ok(format!("http://127.0.0.1:{port}/mcp/{token}")) } #[tauri::command] -fn remove_mcp_from_claude_code() -> Result<(), String> { - let cli = find_claude_cli().ok_or("Claude Code CLI not found")?; - let output = std::process::Command::new(&cli) - .args(["mcp", "remove", "donut-browser"]) - .output() - .map_err(|e| format!("Failed to run claude: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to remove MCP from Claude Code: {stderr}")); +async fn list_mcp_agents() -> Result, String> { + let claude_desktop_connected = is_mcp_in_claude_desktop_internal(); + Ok(mcp_integrations::list_agents_with_status(&[( + "claude-desktop", + claude_desktop_connected, + )])) +} + +#[tauri::command] +async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> { + if !mcp_integrations::agent_exists(&agent_id) { + return Err(format!("Unknown agent: {agent_id}")); } - Ok(()) + if agent_id == "claude-desktop" { + return add_mcp_to_claude_desktop_internal(&app_handle).await; + } + let url = current_mcp_url(&app_handle).await?; + mcp_integrations::install_generic(&agent_id, &url) +} + +#[tauri::command] +async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> { + if !mcp_integrations::agent_exists(&agent_id) { + return Err(format!("Unknown agent: {agent_id}")); + } + if agent_id == "claude-desktop" { + return remove_mcp_from_claude_desktop_internal(); + } + mcp_integrations::uninstall_generic(&agent_id) } #[tauri::command] @@ -2131,12 +2088,9 @@ pub fn run() { stop_mcp_server, get_mcp_server_status, get_mcp_config, - is_mcp_in_claude_desktop, - add_mcp_to_claude_desktop, - remove_mcp_from_claude_desktop, - is_mcp_in_claude_code, - add_mcp_to_claude_code, - remove_mcp_from_claude_code, + list_mcp_agents, + add_mcp_to_agent, + remove_mcp_from_agent, // VPN commands import_vpn_config, list_vpn_configs, diff --git a/src-tauri/src/mcp_integrations.rs b/src-tauri/src/mcp_integrations.rs new file mode 100644 index 0000000..2242572 --- /dev/null +++ b/src-tauri/src/mcp_integrations.rs @@ -0,0 +1,574 @@ +// MCP client integrations — installs/removes the donut-browser MCP server in +// 14 popular AI assistant clients. Ports the add-mcp registry to Rust. +// +// Claude Desktop is managed via Claude's local extensions bundle +// (manifest.json + node bridge), since the desktop app supports only stdio +// servers via its plain JSON config but exposes HTTP through the extension +// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other +// agents (including Claude Code) use the generic config-file installer here. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +const SERVER_NAME: &str = "donut-browser"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum AgentCategory { + DesktopApp, + Cli, + Editor, + EditorExt, +} + +#[derive(Debug, Clone, Copy)] +enum ConfigFormat { + Json, + Toml, + Yaml, +} + +#[derive(Debug, Clone)] +struct AgentSpec { + id: &'static str, + display_name: &'static str, + category: AgentCategory, + /// Top-level key (supports dot notation) where the server is written. + config_key: &'static str, + format: ConfigFormat, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct McpAgentInfo { + pub id: String, + pub display_name: String, + pub category: AgentCategory, + pub connected: bool, + /// True when the underlying client appears to be installed on the system + /// (its config directory exists), regardless of whether we have installed + /// the donut-browser server into it. + pub detected: bool, +} + +fn home() -> Option { + dirs::home_dir() +} + +#[cfg(target_os = "macos")] +fn vscode_user_dir() -> Option { + home().map(|h| { + h.join("Library") + .join("Application Support") + .join("Code") + .join("User") + }) +} + +#[cfg(target_os = "windows")] +fn vscode_user_dir() -> Option { + std::env::var("APPDATA") + .ok() + .map(|a| PathBuf::from(a).join("Code").join("User")) +} + +#[cfg(target_os = "linux")] +fn vscode_user_dir() -> Option { + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| home().map(|h| h.join(".config")))?; + Some(base.join("Code").join("User")) +} + +#[cfg(target_os = "macos")] +fn zed_config_dir() -> Option { + home().map(|h| h.join("Library").join("Application Support").join("Zed")) +} + +#[cfg(target_os = "windows")] +fn zed_config_dir() -> Option { + std::env::var("APPDATA") + .ok() + .map(|a| PathBuf::from(a).join("Zed")) +} + +#[cfg(target_os = "linux")] +fn zed_config_dir() -> Option { + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| home().map(|h| h.join(".config")))?; + Some(base.join("zed")) +} + +#[cfg(target_os = "windows")] +fn goose_config_path() -> Option { + std::env::var("APPDATA").ok().map(|a| { + PathBuf::from(a) + .join("Block") + .join("goose") + .join("config") + .join("config.yaml") + }) +} + +#[cfg(not(target_os = "windows"))] +fn goose_config_path() -> Option { + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| home().map(|h| h.join(".config")))?; + Some(base.join("goose").join("config.yaml")) +} + +/// Resolve the global config path for an agent. Returns `None` on unsupported +/// platforms (none currently — every supported agent has a defined path on +/// macOS/Linux/Windows). +fn config_path_for(agent_id: &str) -> Option { + let h = home()?; + match agent_id { + "antigravity" => Some( + h.join(".gemini") + .join("antigravity") + .join("mcp_config.json"), + ), + "cline" => vscode_user_dir().map(|d| { + d.join("globalStorage") + .join("saoudrizwan.claude-dev") + .join("settings") + .join("cline_mcp_settings.json") + }), + "cline-cli" => { + let base = std::env::var("CLINE_DIR") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| h.join(".cline")); + Some( + base + .join("data") + .join("settings") + .join("cline_mcp_settings.json"), + ) + } + "claude-code" => Some(h.join(".claude.json")), + "claude-desktop" => claude_desktop_config_path(), + "codex" => { + let base = std::env::var("CODEX_HOME") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| h.join(".codex")); + Some(base.join("config.toml")) + } + "cursor" => Some(h.join(".cursor").join("mcp.json")), + "gemini-cli" => Some(h.join(".gemini").join("settings.json")), + "goose" => goose_config_path(), + "github-copilot-cli" => Some( + std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| h.join(".copilot")) + .join("mcp-config.json"), + ), + "mcporter" => { + // add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back + // to mcporter.jsonc if it already exists, else default to mcporter.json. + let dir = h.join(".mcporter"); + let json_path = dir.join("mcporter.json"); + let jsonc_path = dir.join("mcporter.jsonc"); + if json_path.exists() { + Some(json_path) + } else if jsonc_path.exists() { + Some(jsonc_path) + } else { + Some(json_path) + } + } + "opencode" => Some(h.join(".config").join("opencode").join("opencode.json")), + "vscode" => vscode_user_dir().map(|d| d.join("mcp.json")), + "zed" => zed_config_dir().map(|d| d.join("settings.json")), + _ => None, + } +} + +#[cfg(target_os = "macos")] +fn claude_desktop_config_path() -> Option { + home().map(|h| { + h.join("Library") + .join("Application Support") + .join("Claude") + .join("claude_desktop_config.json") + }) +} + +#[cfg(target_os = "windows")] +fn claude_desktop_config_path() -> Option { + std::env::var("APPDATA").ok().map(|a| { + PathBuf::from(a) + .join("Claude") + .join("claude_desktop_config.json") + }) +} + +#[cfg(target_os = "linux")] +fn claude_desktop_config_path() -> Option { + let base = std::env::var("XDG_CONFIG_HOME") + .ok() + .map(PathBuf::from) + .or_else(|| home().map(|h| h.join(".config")))?; + Some(base.join("Claude").join("claude_desktop_config.json")) +} + +const AGENT_SPECS: &[AgentSpec] = &[ + AgentSpec { + id: "claude-desktop", + display_name: "Claude Desktop", + category: AgentCategory::DesktopApp, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "claude-code", + display_name: "Claude Code", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "cursor", + display_name: "Cursor", + category: AgentCategory::Editor, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "vscode", + display_name: "VS Code", + category: AgentCategory::Editor, + config_key: "servers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "zed", + display_name: "Zed", + category: AgentCategory::Editor, + config_key: "context_servers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "cline-cli", + display_name: "Cline CLI", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "cline", + display_name: "Cline VSCode", + category: AgentCategory::EditorExt, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "codex", + display_name: "Codex", + category: AgentCategory::Cli, + config_key: "mcp_servers", + format: ConfigFormat::Toml, + }, + AgentSpec { + id: "gemini-cli", + display_name: "Gemini CLI", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "github-copilot-cli", + display_name: "GitHub Copilot CLI", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "goose", + display_name: "Goose", + category: AgentCategory::Cli, + config_key: "extensions", + format: ConfigFormat::Yaml, + }, + AgentSpec { + id: "antigravity", + display_name: "Antigravity", + category: AgentCategory::DesktopApp, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "opencode", + display_name: "OpenCode", + category: AgentCategory::Cli, + config_key: "mcp", + format: ConfigFormat::Json, + }, + AgentSpec { + id: "mcporter", + display_name: "MCPorter", + category: AgentCategory::Cli, + config_key: "mcpServers", + format: ConfigFormat::Json, + }, +]; + +fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> { + AGENT_SPECS.iter().find(|s| s.id == agent_id) +} + +fn detect_agent_directory(agent_id: &str) -> bool { + // Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate + // parent of the config file. Used only for UI annotation; install/uninstall + // always operates on the resolved config path. + let Some(h) = home() else { + return false; + }; + match agent_id { + "antigravity" => h.join(".gemini").exists(), + "cline" => config_path_for("cline") + .and_then(|p| p.parent().map(|d| d.exists())) + .unwrap_or(false), + "cline-cli" => config_path_for("cline-cli") + .and_then(|p| p.parent().map(|d| d.exists())) + .unwrap_or(false), + "claude-code" => h.join(".claude").exists(), + "claude-desktop" => claude_desktop_config_path() + .and_then(|p| p.parent().map(|d| d.exists())) + .unwrap_or(false), + "codex" => h.join(".codex").exists(), + "cursor" => h.join(".cursor").exists(), + "gemini-cli" => h.join(".gemini").exists(), + "github-copilot-cli" => config_path_for("github-copilot-cli") + .and_then(|p| p.parent().map(|d| d.exists())) + .unwrap_or(false), + "goose" => goose_config_path().is_some_and(|p| p.exists()), + "mcporter" => h.join(".mcporter").exists(), + "opencode" => h.join(".config").join("opencode").exists(), + "vscode" => vscode_user_dir().is_some_and(|d| d.exists()), + "zed" => zed_config_dir().is_some_and(|d| d.exists()), + _ => false, + } +} + +/// Transform the donut-browser HTTP server config into the per-agent shape. +/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge +/// (handled by the extension installer in lib.rs). +fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value { + use serde_json::json; + match agent_id { + "zed" => json!({ "source": "custom", "type": "http", "url": url }), + "opencode" => json!({ "type": "remote", "url": url, "enabled": true }), + "antigravity" => json!({ "serverUrl": url }), + "cursor" => json!({ "url": url }), + "cline" | "cline-cli" => json!({ + "url": url, + "type": "streamableHttp", + "disabled": false, + }), + "codex" => json!({ "type": "http", "url": url }), + "github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }), + "goose" => json!({ + "name": SERVER_NAME, + "description": "", + "type": "streamable_http", + "uri": url, + "headers": {}, + "enabled": true, + "timeout": 300, + }), + "vscode" => json!({ "type": "http", "url": url }), + // claude-code, claude-desktop, gemini-cli, mcporter — passthrough + _ => json!({ "type": "http", "url": url }), + } +} + +/// Detect whether a server config object looks like our donut-browser HTTP +/// endpoint by URL prefix. Matches across the various per-agent key shapes +/// (`url`, `uri`, `serverUrl`). +fn config_matches_donut(value: &serde_json::Value) -> bool { + for key in ["url", "uri", "serverUrl"] { + if let Some(s) = value.get(key).and_then(|v| v.as_str()) { + if s.contains("/mcp/") + && (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost")) + { + return true; + } + } + } + false +} + +fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value { + let Ok(content) = fs::read_to_string(path) else { + return serde_json::Value::Null; + }; + match format { + ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null), + ConfigFormat::Toml => toml::from_str::(&content) + .ok() + .and_then(|t| serde_json::to_value(t).ok()) + .unwrap_or(serde_json::Value::Null), + ConfigFormat::Yaml => serde_yaml::from_str::(&content) + .ok() + .and_then(|y| serde_json::to_value(y).ok()) + .unwrap_or(serde_json::Value::Null), + } +} + +fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?; + } + let content = match format { + ConfigFormat::Json => { + serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))? + } + ConfigFormat::Toml => { + let toml_val: toml::Value = serde_json::from_value(value.clone()) + .map_err(|e| format!("Failed to convert to TOML: {e}"))?; + toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))? + } + ConfigFormat::Yaml => { + let yaml_val: serde_yaml::Value = serde_yaml::from_str( + &serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?, + ) + .map_err(|e| format!("Failed to convert to YAML: {e}"))?; + serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))? + } + }; + fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?; + Ok(()) +} + +/// Navigate `config_key` (dot notation), creating object literals at each +/// missing level. Returns a mutable reference to the bottom container so the +/// caller can set/remove server entries. +fn ensure_nested_object<'a>( + root: &'a mut serde_json::Value, + config_key: &str, +) -> &'a mut serde_json::Map { + if !root.is_object() { + *root = serde_json::Value::Object(serde_json::Map::new()); + } + let mut current = root.as_object_mut().expect("just set to object"); + let parts: Vec<&str> = config_key.split('.').collect(); + for part in &parts { + let entry = current + .entry(part.to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + if !entry.is_object() { + *entry = serde_json::Value::Object(serde_json::Map::new()); + } + current = entry.as_object_mut().expect("just ensured object"); + } + current +} + +fn nested_object<'a>( + root: &'a serde_json::Value, + config_key: &str, +) -> Option<&'a serde_json::Map> { + let mut current = root.as_object()?; + for part in config_key.split('.') { + current = current.get(part)?.as_object()?; + } + Some(current) +} + +fn is_generic_agent_connected(agent_id: &str) -> bool { + let Some(spec) = spec_for(agent_id) else { + return false; + }; + let Some(path) = config_path_for(agent_id) else { + return false; + }; + if !path.exists() { + return false; + } + let root = read_value(&path, spec.format); + let Some(servers) = nested_object(&root, spec.config_key) else { + return false; + }; + if let Some(entry) = servers.get(SERVER_NAME) { + return config_matches_donut(entry); + } + servers.values().any(config_matches_donut) +} + +/// Install or remove the donut-browser entry from a generic agent. Returns +/// `true` if a write happened. Callers handle higher-level dispatch (Claude +/// Desktop extension setup, Claude Code CLI invocation). +pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> { + let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; + let path = config_path_for(agent_id) + .ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?; + + let mut root = if path.exists() { + read_value(&path, spec.format) + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + if !root.is_object() { + root = serde_json::Value::Object(serde_json::Map::new()); + } + + let container = ensure_nested_object(&mut root, spec.config_key); + container.insert( + SERVER_NAME.to_string(), + transform_remote_config(agent_id, url), + ); + + write_value(&path, &root, spec.format) +} + +pub fn uninstall_generic(agent_id: &str) -> Result<(), String> { + let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?; + let Some(path) = config_path_for(agent_id) else { + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + + let mut root = read_value(&path, spec.format); + if !root.is_object() { + return Ok(()); + } + + let container = ensure_nested_object(&mut root, spec.config_key); + container.remove(SERVER_NAME); + + write_value(&path, &root, spec.format) +} + +pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec { + AGENT_SPECS + .iter() + .map(|spec| { + let connected = connected_overrides + .iter() + .find(|(id, _)| *id == spec.id) + .map(|(_, c)| *c) + .unwrap_or_else(|| is_generic_agent_connected(spec.id)); + McpAgentInfo { + id: spec.id.to_string(), + display_name: spec.display_name.to_string(), + category: spec.category, + connected, + detected: detect_agent_directory(spec.id), + } + }) + .collect() +} + +pub fn agent_exists(agent_id: &str) -> bool { + spec_for(agent_id).is_some() +} diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 8b9054b..dfc75a1 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -12,6 +12,20 @@ use std::path::{Path, PathBuf}; use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; use url::Url; +fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) { + Some(ext) => format!("{ext}.tmp"), + None => "tmp".to_string(), + }); + { + let mut f = fs::File::create(&tmp)?; + use std::io::Write; + f.write_all(data)?; + f.sync_all()?; + } + fs::rename(&tmp, path) +} + pub struct ProfileManager { camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager, wayfern_manager: &'static crate::wayfern_manager::WayfernManager, @@ -396,7 +410,7 @@ impl ProfileManager { create_dir_all(&profile_uuid_dir)?; let json = serde_json::to_string_pretty(profile)?; - fs::write(profile_file, json)?; + atomic_write(&profile_file, json.as_bytes())?; // Update tag suggestions after any save let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| { @@ -421,8 +435,26 @@ impl ProfileManager { if path.is_dir() { let metadata_file = path.join("metadata.json"); if metadata_file.exists() { - let content = fs::read_to_string(&metadata_file)?; - let mut profile: BrowserProfile = serde_json::from_str(&content)?; + let content = match fs::read_to_string(&metadata_file) { + Ok(c) => c, + Err(e) => { + log::warn!( + "Skipping profile at {}: failed to read metadata.json: {e}", + path.display() + ); + continue; + } + }; + let mut profile: BrowserProfile = match serde_json::from_str(&content) { + Ok(p) => p, + Err(e) => { + log::warn!( + "Skipping profile at {}: invalid metadata.json: {e}", + path.display() + ); + continue; + } + }; // Backfill host_os from browser config for profiles created before // the field existed (or synced without it). @@ -431,7 +463,7 @@ impl ProfileManager { if let Some(os) = inferred_os { profile.host_os = Some(os); if let Ok(json) = serde_json::to_string_pretty(&profile) { - let _ = fs::write(&metadata_file, json); + let _ = atomic_write(&metadata_file, json.as_bytes()); } } } diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index f30f0eb..647ad9f 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -4,7 +4,15 @@ import { invoke } from "@tauri-apps/api/core"; import { Eye, EyeOff } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { LuPlug } from "react-icons/lu"; +import { + LuAppWindow, + LuCheck, + LuCodeXml, + LuPlug, + LuTerminal, + LuTrash2, + LuZap, +} from "react-icons/lu"; import { AnimatedSwitch } from "@/components/ui/animated-switch"; import { AnimatedTabs, @@ -13,7 +21,6 @@ import { AnimatedTabsTrigger, } from "@/components/ui/animated-tabs"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -23,6 +30,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; +import { translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { CopyToClipboard } from "./ui/copy-to-clipboard"; @@ -40,12 +48,52 @@ interface McpConfig { token: string; } +type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext"; + +interface McpAgentInfo { + id: string; + display_name: string; + category: AgentCategory; + connected: boolean; + detected: boolean; +} + interface IntegrationsDialogProps { isOpen: boolean; onClose: () => void; subPage?: boolean; } +function AgentIcon({ category }: { category: AgentCategory }) { + const className = "size-4 text-muted-foreground"; + switch (category) { + case "desktop-app": + return ; + case "editor": + return ; + case "editor-ext": + return ; + case "cli": + return ; + } +} + +function categoryLabel( + t: (k: string) => string, + category: AgentCategory, +): string { + switch (category) { + case "desktop-app": + return t("integrations.mcp.category.desktopApp"); + case "editor": + return t("integrations.mcp.category.editor"); + case "editor-ext": + return t("integrations.mcp.category.editorExt"); + case "cli": + return t("integrations.mcp.category.cli"); + } +} + export function IntegrationsDialog({ isOpen, onClose, @@ -64,11 +112,11 @@ export function IntegrationsDialog({ const [mcpConfig, setMcpConfig] = useState(null); const [, setMcpRunning] = useState(false); const [showApiToken, setShowApiToken] = useState(false); - const [showMcpToken, setShowMcpToken] = useState(false); + const [showMcpUrl, setShowMcpUrl] = useState(false); const [isApiStarting, setIsApiStarting] = useState(false); const [isMcpStarting, setIsMcpStarting] = useState(false); - const [mcpInClaudeDesktop, setMcpInClaudeDesktop] = useState(false); - const [mcpInClaudeCode, setMcpInClaudeCode] = useState(false); + const [agents, setAgents] = useState([]); + const [busyAgentIds, setBusyAgentIds] = useState>(new Set()); const { termsAccepted } = useWayfernTerms(); @@ -108,21 +156,12 @@ export function IntegrationsDialog({ } }, []); - const loadClaudeDesktopStatus = useCallback(async () => { + const loadAgents = useCallback(async () => { try { - const exists = await invoke("is_mcp_in_claude_desktop"); - setMcpInClaudeDesktop(exists); - } catch { - // Not critical - } - }, []); - - const loadClaudeCodeStatus = useCallback(async () => { - try { - const exists = await invoke("is_mcp_in_claude_code"); - setMcpInClaudeCode(exists); - } catch { - // Claude CLI may not be installed + const list = await invoke("list_mcp_agents"); + setAgents(list); + } catch (e) { + console.error("Failed to list MCP agents:", e); } }, []); @@ -132,8 +171,7 @@ export function IntegrationsDialog({ void loadApiServerStatus(); void loadMcpConfig(); void loadMcpServerStatus(); - void loadClaudeDesktopStatus(); - void loadClaudeCodeStatus(); + void loadAgents(); } }, [ isOpen, @@ -141,8 +179,7 @@ export function IntegrationsDialog({ loadApiServerStatus, loadMcpConfig, loadMcpServerStatus, - loadClaudeDesktopStatus, - loadClaudeCodeStatus, + loadAgents, ]); const handleApiToggle = async (enabled: boolean) => { @@ -188,6 +225,7 @@ export function IntegrationsDialog({ }); setSettings(next); void loadMcpConfig(); + void loadAgents(); showSuccessToast(t("integrations.mcpStarted", { port })); } else { await invoke("stop_mcp_server"); @@ -209,6 +247,53 @@ export function IntegrationsDialog({ } }; + const markAgentBusy = (id: string, busy: boolean) => { + setBusyAgentIds((prev) => { + const next = new Set(prev); + if (busy) next.add(id); + else next.delete(id); + return next; + }); + }; + + const handleAddAgent = async (agent: McpAgentInfo) => { + markAgentBusy(agent.id, true); + try { + await invoke("add_mcp_to_agent", { agentId: agent.id }); + showSuccessToast( + t("integrations.mcp.addedToClient", { name: agent.display_name }), + ); + void loadAgents(); + } catch (e) { + showErrorToast(translateBackendError(t, e), { + description: agent.display_name, + }); + } finally { + markAgentBusy(agent.id, false); + } + }; + + const handleRemoveAgent = async (agent: McpAgentInfo) => { + markAgentBusy(agent.id, true); + try { + await invoke("remove_mcp_from_agent", { agentId: agent.id }); + showSuccessToast( + t("integrations.mcp.removedFromClient", { name: agent.display_name }), + ); + void loadAgents(); + } catch (e) { + showErrorToast(translateBackendError(t, e), { + description: agent.display_name, + }); + } finally { + markAgentBusy(agent.id, false); + } + }; + + const mcpUrl = mcpConfig + ? `http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}` + : ""; + return ( - + {!subPage && ( {t("integrations.title")} @@ -413,44 +498,47 @@ export function IntegrationsDialog({ )} - -
- void handleMcpToggle(!!checked)} - className="mt-0.5" - /> -
- -

- {t("integrations.mcpEnableDescription")} - {!termsAccepted && ( - - {t("integrations.mcpAcceptTermsFirst")} - - )} -

+ +
+
+
+ +
+ +

+ {t("integrations.mcpEnableDescription")} + {!termsAccepted && ( + + {t("integrations.mcpAcceptTermsFirst")} + + )} +

+
+
+ void handleMcpToggle(checked)} + />
{mcpConfig && ( -
-
+ <> +
@@ -460,10 +548,10 @@ export function IntegrationsDialog({ size="sm" className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" onClick={() => { - setShowMcpToken(!showMcpToken); + setShowMcpUrl(!showMcpUrl); }} > - {showMcpToken ? ( + {showMcpUrl ? ( ) : ( @@ -471,102 +559,74 @@ export function IntegrationsDialog({
-
+
-
- - {t("integrations.mcp.claudeDesktopTitle")} - - {mcpInClaudeDesktop ? ( - - ) : ( - - )} -
-
- - {t("integrations.mcp.claudeCodeTitle")} - - {mcpInClaudeCode ? ( - - ) : ( - - )} +
+ {agents.map((agent) => { + const busy = busyAgentIds.has(agent.id); + return ( +
+
+ +
+
+

+ {agent.display_name} +

+

+ {categoryLabel(t, agent.category)} +

+
+ {agent.connected ? ( +
+ + + {t("integrations.mcp.connected")} + + +
+ ) : ( + + )} +
+ ); + })}
-
+ )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a49c252..689d836 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -656,20 +656,20 @@ "tokenCopied": "Token copied", "url": "MCP Server URL", "urlCopied": "URL copied", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "Configures claude_desktop_config.json automatically", - "addToClaudeDesktop": "Add to Claude Desktop", - "removeFromClaudeDesktop": "Remove from Claude Desktop", - "addedToClaudeDesktop": "Added to Claude Desktop. Restart Claude Desktop and enable the extension in Settings.", - "removedFromClaudeDesktop": "Removed from Claude Desktop config. Please restart Claude Desktop.", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "Add to Claude Code", - "removeFromClaudeCode": "Remove from Claude Code", - "addedToClaudeCode": "Added to Claude Code", - "removedFromClaudeCode": "Removed from Claude Code", "config": "MCP Configuration", "copyConfig": "Copy Configuration", - "clientsLabel": "Clients" + "clientsLabel": "Clients", + "connected": "Connected", + "add": "Add", + "addedToClient": "Added to {{name}}", + "removedFromClient": "Removed from {{name}}", + "removeAriaLabel": "Remove from {{name}}", + "category": { + "desktopApp": "Desktop app", + "cli": "CLI", + "editor": "Editor", + "editorExt": "Editor ext" + } }, "tabApi": "Local API", "tabMcp": "MCP (AI Assistants)", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d70c0a0..be3fb55 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -656,20 +656,20 @@ "tokenCopied": "Token copiado", "url": "URL del servidor MCP", "urlCopied": "URL copiada", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "Configura claude_desktop_config.json automáticamente", - "addToClaudeDesktop": "Agregar a Claude Desktop", - "removeFromClaudeDesktop": "Eliminar de Claude Desktop", - "addedToClaudeDesktop": "Agregado a Claude Desktop. Reinicia Claude Desktop y activa la extensión en Configuración.", - "removedFromClaudeDesktop": "Eliminado de la configuración de Claude Desktop. Reinicia Claude Desktop.", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "Agregar a Claude Code", - "removeFromClaudeCode": "Eliminar de Claude Code", - "addedToClaudeCode": "Agregado a Claude Code", - "removedFromClaudeCode": "Eliminado de Claude Code", "config": "Configuración MCP", "copyConfig": "Copiar Configuración", - "clientsLabel": "Clientes" + "clientsLabel": "Clientes", + "connected": "Conectado", + "add": "Agregar", + "addedToClient": "Agregado a {{name}}", + "removedFromClient": "Eliminado de {{name}}", + "removeAriaLabel": "Eliminar de {{name}}", + "category": { + "desktopApp": "Aplicación de escritorio", + "cli": "CLI", + "editor": "Editor", + "editorExt": "Extensión de editor" + } }, "tabApi": "API local", "tabMcp": "MCP (asistentes IA)", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 00eeffa..795473e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -656,20 +656,20 @@ "tokenCopied": "Jeton copié", "url": "URL du serveur MCP", "urlCopied": "URL copiée", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "Configure claude_desktop_config.json automatiquement", - "addToClaudeDesktop": "Ajouter à Claude Desktop", - "removeFromClaudeDesktop": "Supprimer de Claude Desktop", - "addedToClaudeDesktop": "Ajouté à Claude Desktop. Redémarrez Claude Desktop et activez l'extension dans les Paramètres.", - "removedFromClaudeDesktop": "Supprimé de la configuration de Claude Desktop. Veuillez redémarrer Claude Desktop.", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "Ajouter à Claude Code", - "removeFromClaudeCode": "Supprimer de Claude Code", - "addedToClaudeCode": "Ajouté à Claude Code", - "removedFromClaudeCode": "Supprimé de Claude Code", "config": "Configuration MCP", "copyConfig": "Copier la configuration", - "clientsLabel": "Clients" + "clientsLabel": "Clients", + "connected": "Connecté", + "add": "Ajouter", + "addedToClient": "Ajouté à {{name}}", + "removedFromClient": "Supprimé de {{name}}", + "removeAriaLabel": "Supprimer de {{name}}", + "category": { + "desktopApp": "Application bureau", + "cli": "CLI", + "editor": "Éditeur", + "editorExt": "Ext. d'éditeur" + } }, "tabApi": "API locale", "tabMcp": "MCP (Assistants IA)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 244ba1d..3bb9d24 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -656,20 +656,20 @@ "tokenCopied": "トークンをコピーしました", "url": "MCPサーバーURL", "urlCopied": "URLをコピーしました", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "claude_desktop_config.json を自動的に設定します", - "addToClaudeDesktop": "Claude Desktop に追加", - "removeFromClaudeDesktop": "Claude Desktop から削除", - "addedToClaudeDesktop": "Claude Desktop に追加しました。Claude Desktop を再起動し、設定で拡張機能を有効にしてください。", - "removedFromClaudeDesktop": "Claude Desktop の設定から削除しました。Claude Desktop を再起動してください。", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "Claude Code に追加", - "removeFromClaudeCode": "Claude Code から削除", - "addedToClaudeCode": "Claude Code に追加しました", - "removedFromClaudeCode": "Claude Code から削除しました", "config": "MCP設定", "copyConfig": "設定をコピー", - "clientsLabel": "クライアント" + "clientsLabel": "クライアント", + "connected": "接続済み", + "add": "追加", + "addedToClient": "{{name}} に追加しました", + "removedFromClient": "{{name}} から削除しました", + "removeAriaLabel": "{{name}} から削除", + "category": { + "desktopApp": "デスクトップアプリ", + "cli": "CLI", + "editor": "エディタ", + "editorExt": "エディタ拡張" + } }, "tabApi": "ローカル API", "tabMcp": "MCP (AI アシスタント)", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 31532ba..4f0f684 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -656,20 +656,20 @@ "tokenCopied": "Token copiado", "url": "URL do servidor MCP", "urlCopied": "URL copiada", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "Configura claude_desktop_config.json automaticamente", - "addToClaudeDesktop": "Adicionar ao Claude Desktop", - "removeFromClaudeDesktop": "Remover do Claude Desktop", - "addedToClaudeDesktop": "Adicionado ao Claude Desktop. Reinicie o Claude Desktop e ative a extensão em Configurações.", - "removedFromClaudeDesktop": "Removido da configuração do Claude Desktop. Reinicie o Claude Desktop.", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "Adicionar ao Claude Code", - "removeFromClaudeCode": "Remover do Claude Code", - "addedToClaudeCode": "Adicionado ao Claude Code", - "removedFromClaudeCode": "Removido do Claude Code", "config": "Configuração MCP", "copyConfig": "Copiar Configuração", - "clientsLabel": "Clientes" + "clientsLabel": "Clientes", + "connected": "Conectado", + "add": "Adicionar", + "addedToClient": "Adicionado a {{name}}", + "removedFromClient": "Removido de {{name}}", + "removeAriaLabel": "Remover de {{name}}", + "category": { + "desktopApp": "Aplicativo de desktop", + "cli": "CLI", + "editor": "Editor", + "editorExt": "Extensão de editor" + } }, "tabApi": "API local", "tabMcp": "MCP (Assistentes de IA)", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 00030b9..4d9be72 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -656,20 +656,20 @@ "tokenCopied": "Токен скопирован", "url": "URL MCP сервера", "urlCopied": "URL скопирован", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "Автоматически настраивает claude_desktop_config.json", - "addToClaudeDesktop": "Добавить в Claude Desktop", - "removeFromClaudeDesktop": "Удалить из Claude Desktop", - "addedToClaudeDesktop": "Добавлено в Claude Desktop. Перезапустите Claude Desktop и включите расширение в Настройках.", - "removedFromClaudeDesktop": "Удалено из конфигурации Claude Desktop. Перезапустите Claude Desktop.", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "Добавить в Claude Code", - "removeFromClaudeCode": "Удалить из Claude Code", - "addedToClaudeCode": "Добавлено в Claude Code", - "removedFromClaudeCode": "Удалено из Claude Code", "config": "Конфигурация MCP", "copyConfig": "Копировать конфигурацию", - "clientsLabel": "Клиенты" + "clientsLabel": "Клиенты", + "connected": "Подключено", + "add": "Добавить", + "addedToClient": "Добавлено в {{name}}", + "removedFromClient": "Удалено из {{name}}", + "removeAriaLabel": "Удалить из {{name}}", + "category": { + "desktopApp": "Десктоп-приложение", + "cli": "CLI", + "editor": "Редактор", + "editorExt": "Расширение редактора" + } }, "tabApi": "Локальный API", "tabMcp": "MCP (ИИ-ассистенты)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8c835f5..9553664 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -656,20 +656,20 @@ "tokenCopied": "令牌已复制", "url": "MCP 服务器 URL", "urlCopied": "URL 已复制", - "claudeDesktopTitle": "Claude Desktop", - "claudeDesktopHint": "自动配置 claude_desktop_config.json", - "addToClaudeDesktop": "添加到 Claude Desktop", - "removeFromClaudeDesktop": "从 Claude Desktop 移除", - "addedToClaudeDesktop": "已添加到 Claude Desktop。请重启 Claude Desktop 并在设置中启用扩展。", - "removedFromClaudeDesktop": "已从 Claude Desktop 配置移除。请重启 Claude Desktop。", - "claudeCodeTitle": "Claude Code", - "addToClaudeCode": "添加到 Claude Code", - "removeFromClaudeCode": "从 Claude Code 移除", - "addedToClaudeCode": "已添加到 Claude Code", - "removedFromClaudeCode": "已从 Claude Code 移除", "config": "MCP 配置", "copyConfig": "复制配置", - "clientsLabel": "客户端" + "clientsLabel": "客户端", + "connected": "已连接", + "add": "添加", + "addedToClient": "已添加到 {{name}}", + "removedFromClient": "已从 {{name}} 移除", + "removeAriaLabel": "从 {{name}} 移除", + "category": { + "desktopApp": "桌面应用", + "cli": "CLI", + "editor": "编辑器", + "editorExt": "编辑器扩展" + } }, "tabApi": "本地 API", "tabMcp": "MCP (AI 助手)",