diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 9300a6c..9d06d7c 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -42,7 +42,10 @@ fn mcp_network(message: impl Into) -> AppCommandError { pub enum McpAppType { ClaudeCode, Codex, + Gemini, + OpenClaw, OpenCode, + Cline, } #[derive(Debug, Clone, Serialize)] @@ -350,7 +353,10 @@ pub async fn mcp_upsert_local_server( let all_apps = [ McpAppType::ClaudeCode, McpAppType::Codex, + McpAppType::Gemini, + McpAppType::OpenClaw, McpAppType::OpenCode, + McpAppType::Cline, ]; for app in all_apps { @@ -401,7 +407,10 @@ pub async fn mcp_remove_server( None => vec![ McpAppType::ClaudeCode, McpAppType::Codex, + McpAppType::Gemini, + McpAppType::OpenClaw, McpAppType::OpenCode, + McpAppType::Cline, ], }; @@ -537,6 +546,24 @@ fn opencode_config_path() -> PathBuf { .join("config.json") } +fn gemini_config_path() -> PathBuf { + home_dir_or_default().join(".gemini").join("settings.json") +} + +fn openclaw_config_path() -> PathBuf { + home_dir_or_default() + .join(".openclaw") + .join("openclaw.json") +} + +fn cline_config_path() -> PathBuf { + home_dir_or_default() + .join(".cline") + .join("data") + .join("settings") + .join("cline_mcp_settings.json") +} + fn read_json_file(path: &Path) -> Result { if !path.exists() { return Ok(json!({})); @@ -1584,6 +1611,253 @@ fn remove_opencode_server(id: &str) -> Result { Ok(removed) } +// --------------------------------------------------------------------------- +// Gemini CLI (~/.gemini/settings.json → mcpServers) +// --------------------------------------------------------------------------- + +fn read_gemini_servers() -> Result, AppCommandError> { + let path = gemini_config_path(); + let root = read_json_file(&path)?; + let mut out = BTreeMap::new(); + + let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { + return Ok(out); + }; + + for (id, spec) in servers { + match canonicalize_spec(spec, "Gemini config") { + Ok(normalized) => { + out.insert(id.to_string(), normalized); + } + Err(err) => { + eprintln!("[MCP] skip invalid Gemini MCP entry id={id}: {err}"); + } + } + } + + Ok(out) +} + +fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { + let path = gemini_config_path(); + let mut root = read_json_file(&path)?; + if !root.is_object() { + root = json!({}); + } + + let canonical = canonicalize_spec(spec, "Gemini write")?; + + let obj = root.as_object_mut().ok_or_else(|| { + mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) + })?; + if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { + obj.insert("mcpServers".to_string(), Value::Object(Map::new())); + } + + let map = obj + .get_mut("mcpServers") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) + })?; + map.insert(id.to_string(), canonical); + + write_json_file(&path, &root) +} + +fn remove_gemini_server(id: &str) -> Result { + let path = gemini_config_path(); + if !path.exists() { + return Ok(false); + } + + let mut root = read_json_file(&path)?; + let Some(obj) = root.as_object_mut() else { + return Ok(false); + }; + let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let removed = servers.remove(id).is_some(); + if removed { + write_json_file(&path, &root)?; + } + Ok(removed) +} + +// --------------------------------------------------------------------------- +// OpenClaw (~/.openclaw/openclaw.json → mcp.servers) +// --------------------------------------------------------------------------- + +fn read_openclaw_servers() -> Result, AppCommandError> { + let path = openclaw_config_path(); + let root = read_json_file(&path)?; + let mut out = BTreeMap::new(); + + let Some(mcp) = root.get("mcp").and_then(Value::as_object) else { + return Ok(out); + }; + let Some(servers) = mcp.get("servers").and_then(Value::as_object) else { + return Ok(out); + }; + + for (id, spec) in servers { + match canonicalize_spec(spec, "OpenClaw config") { + Ok(normalized) => { + out.insert(id.to_string(), normalized); + } + Err(err) => { + eprintln!("[MCP] skip invalid OpenClaw MCP entry id={id}: {err}"); + } + } + } + + Ok(out) +} + +fn upsert_openclaw_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { + let path = openclaw_config_path(); + let mut root = read_json_file(&path)?; + if !root.is_object() { + root = json!({}); + } + + let canonical = canonicalize_spec(spec, "OpenClaw write")?; + + let obj = root.as_object_mut().ok_or_else(|| { + mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) + })?; + + if !obj.get("mcp").map(Value::is_object).unwrap_or(false) { + obj.insert("mcp".to_string(), json!({})); + } + let mcp = obj + .get_mut("mcp") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + mcp_configuration_invalid(format!("invalid mcp in {}", path.display())) + })?; + + if !mcp.get("servers").map(Value::is_object).unwrap_or(false) { + mcp.insert("servers".to_string(), Value::Object(Map::new())); + } + let servers = mcp + .get_mut("servers") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + mcp_configuration_invalid(format!("invalid mcp.servers in {}", path.display())) + })?; + servers.insert(id.to_string(), canonical); + + write_json_file(&path, &root) +} + +fn remove_openclaw_server(id: &str) -> Result { + let path = openclaw_config_path(); + if !path.exists() { + return Ok(false); + } + + let mut root = read_json_file(&path)?; + let Some(obj) = root.as_object_mut() else { + return Ok(false); + }; + let Some(mcp) = obj.get_mut("mcp").and_then(Value::as_object_mut) else { + return Ok(false); + }; + let Some(servers) = mcp.get_mut("servers").and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let removed = servers.remove(id).is_some(); + if removed { + if servers.is_empty() { + mcp.remove("servers"); + } + if mcp.is_empty() { + obj.remove("mcp"); + } + write_json_file(&path, &root)?; + } + Ok(removed) +} + +// --------------------------------------------------------------------------- +// Cline (~/.cline/data/settings/cline_mcp_settings.json → mcpServers) +// --------------------------------------------------------------------------- + +fn read_cline_servers() -> Result, AppCommandError> { + let path = cline_config_path(); + let root = read_json_file(&path)?; + let mut out = BTreeMap::new(); + + let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { + return Ok(out); + }; + + for (id, spec) in servers { + match canonicalize_spec(spec, "Cline config") { + Ok(normalized) => { + out.insert(id.to_string(), normalized); + } + Err(err) => { + eprintln!("[MCP] skip invalid Cline MCP entry id={id}: {err}"); + } + } + } + + Ok(out) +} + +fn upsert_cline_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { + let path = cline_config_path(); + let mut root = read_json_file(&path)?; + if !root.is_object() { + root = json!({}); + } + + let canonical = canonicalize_spec(spec, "Cline write")?; + + let obj = root.as_object_mut().ok_or_else(|| { + mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) + })?; + if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { + obj.insert("mcpServers".to_string(), Value::Object(Map::new())); + } + + let map = obj + .get_mut("mcpServers") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) + })?; + map.insert(id.to_string(), canonical); + + write_json_file(&path, &root) +} + +fn remove_cline_server(id: &str) -> Result { + let path = cline_config_path(); + if !path.exists() { + return Ok(false); + } + + let mut root = read_json_file(&path)?; + let Some(obj) = root.as_object_mut() else { + return Ok(false); + }; + let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { + return Ok(false); + }; + + let removed = servers.remove(id).is_some(); + if removed { + write_json_file(&path, &root)?; + } + Ok(removed) +} + fn scan_local_servers() -> Result, AppCommandError> { let mut merged: BTreeMap)> = BTreeMap::new(); @@ -1608,6 +1882,27 @@ fn scan_local_servers() -> Result, AppCommandError> { entry.1.insert(McpAppType::OpenCode); } + for (id, spec) in read_gemini_servers()? { + let entry = merged + .entry(id) + .or_insert_with(|| (spec.clone(), BTreeSet::new())); + entry.1.insert(McpAppType::Gemini); + } + + for (id, spec) in read_openclaw_servers()? { + let entry = merged + .entry(id) + .or_insert_with(|| (spec.clone(), BTreeSet::new())); + entry.1.insert(McpAppType::OpenClaw); + } + + for (id, spec) in read_cline_servers()? { + let entry = merged + .entry(id) + .or_insert_with(|| (spec.clone(), BTreeSet::new())); + entry.1.insert(McpAppType::Cline); + } + Ok(merged .into_iter() .map(|(id, (spec, apps))| LocalMcpServer { @@ -1628,6 +1923,9 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::ClaudeCode => upsert_claude_server(id, spec), McpAppType::Codex => upsert_codex_server(id, spec), McpAppType::OpenCode => upsert_opencode_server(id, spec), + McpAppType::Gemini => upsert_gemini_server(id, spec), + McpAppType::OpenClaw => upsert_openclaw_server(id, spec), + McpAppType::Cline => upsert_cline_server(id, spec), } } @@ -1636,6 +1934,9 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result remove_claude_server(id), McpAppType::Codex => remove_codex_server(id), McpAppType::OpenCode => remove_opencode_server(id), + McpAppType::Gemini => remove_gemini_server(id), + McpAppType::OpenClaw => remove_openclaw_server(id), + McpAppType::Cline => remove_cline_server(id), } } diff --git a/src/components/settings/mcp-settings.tsx b/src/components/settings/mcp-settings.tsx index 81d96c6..ef7232e 100644 --- a/src/components/settings/mcp-settings.tsx +++ b/src/components/settings/mcp-settings.tsx @@ -69,9 +69,12 @@ type McpTranslator = ( ) => string const APP_OPTIONS: { value: McpAppType; label: string }[] = [ - { value: "claude_code", label: "Claude" }, - { value: "codex", label: "Codex" }, + { value: "claude_code", label: "Claude Code" }, + { value: "codex", label: "Codex CLI" }, + { value: "gemini", label: "Gemini CLI" }, + { value: "open_claw", label: "OpenClaw" }, { value: "open_code", label: "OpenCode" }, + { value: "cline", label: "Cline" }, ] function isObject(value: unknown): value is Record { @@ -228,7 +231,10 @@ function appsToDraft(apps: McpAppType[]): Record { return { claude_code: appSet.has("claude_code"), codex: appSet.has("codex"), + gemini: appSet.has("gemini"), + open_claw: appSet.has("open_claw"), open_code: appSet.has("open_code"), + cline: appSet.has("cline"), } } diff --git a/src/lib/types.ts b/src/lib/types.ts index e4c19f8..20b8b8f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -573,7 +573,13 @@ export interface GitHubTokenValidation { message: string | null } -export type McpAppType = "claude_code" | "codex" | "open_code" +export type McpAppType = + | "claude_code" + | "codex" + | "gemini" + | "open_claw" + | "open_code" + | "cline" export interface LocalMcpServer { id: string