diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 55fee60d..1081a280 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -8,10 +8,12 @@ //! - `claude` - Claude MCP 同步和导入 //! - `codex` - Codex MCP 同步和导入(含 TOML 转换) //! - `gemini` - Gemini MCP 同步和导入 +//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换) mod claude; mod codex; mod gemini; +mod opencode; mod validation; // 重新导出公共 API @@ -26,3 +28,7 @@ pub use gemini::{ import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini, sync_single_server_to_gemini, }; +pub use opencode::{ + import_from_opencode, remove_server_from_opencode, sync_enabled_to_opencode, + sync_single_server_to_opencode, +}; diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs new file mode 100644 index 00000000..45a8c42d --- /dev/null +++ b/src-tauri/src/mcp/opencode.rs @@ -0,0 +1,412 @@ +//! OpenCode MCP 同步和导入模块 +//! +//! 本模块处理 CC Switch 统一 MCP 格式与 OpenCode 格式之间的转换。 +//! +//! ## 格式差异 +//! +//! | CC Switch 统一格式 | OpenCode 格式 | +//! |----------------------|---------------------| +//! | `type: "stdio"` | `type: "local"` | +//! | `command` + `args` | `command: [cmd, ...args]` | +//! | `env` | `environment` | +//! | `type: "sse"/"http"` | `type: "remote"` | +//! | `url` | `url` | + +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::app_config::{McpApps, McpConfig, McpServer, MultiAppConfig}; +use crate::error::AppError; +use crate::opencode_config; + +use super::validation::{extract_server_spec, validate_server_spec}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Check if OpenCode MCP sync should proceed +fn should_sync_opencode_mcp() -> bool { + // Skip if OpenCode config directory doesn't exist + opencode_config::get_opencode_dir().exists() +} + +/// Collect enabled MCP servers for OpenCode +fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { + let mut out = HashMap::new(); + for (id, entry) in cfg.servers.iter() { + let enabled = entry + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + continue; + } + match extract_server_spec(entry) { + Ok(spec) => { + out.insert(id.clone(), spec); + } + Err(err) => { + log::warn!("Skip invalid MCP entry '{}': {}", id, err); + } + } + } + out +} + +// ============================================================================ +// Format Conversion: CC Switch → OpenCode +// ============================================================================ + +/// Convert CC Switch unified format to OpenCode format +/// +/// Conversion rules: +/// - `stdio` → `local`, command+args → command array, env → environment +/// - `sse`/`http` → `remote`, url preserved +pub fn convert_to_opencode_format(spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?; + + let typ = obj + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("stdio"); + + let mut result = serde_json::Map::new(); + + match typ { + "stdio" => { + // Convert to "local" type + result.insert("type".into(), json!("local")); + + // Merge command and args into a single array + let cmd = obj + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut command_arr = vec![json!(cmd)]; + + if let Some(args) = obj.get("args").and_then(|v| v.as_array()) { + for arg in args { + command_arr.push(arg.clone()); + } + } + result.insert("command".into(), Value::Array(command_arr)); + + // Convert env → environment + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("environment".into(), env.clone()); + } + } + + // Add enabled flag (OpenCode expects this) + result.insert("enabled".into(), json!(true)); + } + "sse" | "http" => { + // Convert to "remote" type + result.insert("type".into(), json!("remote")); + + // Preserve url + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + + // Convert headers if present + if let Some(headers) = obj.get("headers") { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + { + result.insert("headers".into(), headers.clone()); + } + } + + // Add enabled flag + result.insert("enabled".into(), json!(true)); + } + _ => { + return Err(AppError::McpValidation(format!( + "Unknown MCP type: {}", + typ + ))); + } + } + + Ok(Value::Object(result)) +} + +// ============================================================================ +// Format Conversion: OpenCode → CC Switch +// ============================================================================ + +/// Convert OpenCode format to CC Switch unified format +/// +/// Conversion rules: +/// - `local` → `stdio`, command array → command+args, environment → env +/// - `remote` → `sse`, url preserved +pub fn convert_from_opencode_format(spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?; + + let typ = obj + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("local"); + + let mut result = serde_json::Map::new(); + + match typ { + "local" => { + // Convert to "stdio" type + result.insert("type".into(), json!("stdio")); + + // Split command array into command and args + if let Some(cmd_arr) = obj.get("command").and_then(|v| v.as_array()) { + if !cmd_arr.is_empty() { + // First element is the command + if let Some(cmd) = cmd_arr.first().and_then(|v| v.as_str()) { + result.insert("command".into(), json!(cmd)); + } + + // Rest are args + if cmd_arr.len() > 1 { + let args: Vec = cmd_arr[1..].to_vec(); + result.insert("args".into(), Value::Array(args)); + } + } + } + + // Convert environment → env + if let Some(env) = obj.get("environment") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } + "remote" => { + // Convert to "sse" type (default remote protocol) + result.insert("type".into(), json!("sse")); + + // Preserve url + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + + // Preserve headers + if let Some(headers) = obj.get("headers") { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + { + result.insert("headers".into(), headers.clone()); + } + } + } + _ => { + return Err(AppError::McpValidation(format!( + "Unknown OpenCode MCP type: {}", + typ + ))); + } + } + + Ok(Value::Object(result)) +} + +// ============================================================================ +// Public API: Sync Functions +// ============================================================================ + +/// Sync all enabled_opencode=true servers to OpenCode config +pub fn sync_enabled_to_opencode(config: &MultiAppConfig) -> Result<(), AppError> { + if !should_sync_opencode_mcp() { + return Ok(()); + } + + let enabled = collect_enabled_servers(&config.mcp.opencode); + + // Convert all servers to OpenCode format + let mut opencode_servers = serde_json::Map::new(); + for (id, spec) in enabled { + match convert_to_opencode_format(&spec) { + Ok(opencode_spec) => { + opencode_servers.insert(id, opencode_spec); + } + Err(e) => { + log::warn!("Skip converting MCP server to OpenCode format: {}", e); + } + } + } + + // Write to OpenCode config + opencode_config::set_mcp_servers_batch(&opencode_servers) +} + +/// Sync a single MCP server to OpenCode live config +pub fn sync_single_server_to_opencode( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !should_sync_opencode_mcp() { + return Ok(()); + } + + // Convert to OpenCode format + let opencode_spec = convert_to_opencode_format(server_spec)?; + + // Set in OpenCode config + opencode_config::set_mcp_server(id, opencode_spec) +} + +/// Remove a single MCP server from OpenCode live config +pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> { + if !should_sync_opencode_mcp() { + return Ok(()); + } + + opencode_config::remove_mcp_server(id) +} + +/// Import MCP servers from OpenCode config to unified structure +/// +/// Existing servers will have OpenCode app enabled without overwriting other fields. +pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result { + let mcp_map = opencode_config::get_mcp_servers()?; + if mcp_map.is_empty() { + return Ok(0); + } + + // Ensure servers map exists + let servers = config.mcp.servers.get_or_insert_with(HashMap::new); + + let mut changed = 0; + let mut errors = Vec::new(); + + for (id, spec) in mcp_map { + // Convert from OpenCode format to unified format + let unified_spec = match convert_from_opencode_format(&spec) { + Ok(s) => s, + Err(e) => { + log::warn!("Skip invalid OpenCode MCP server '{}': {}", id, e); + errors.push(format!("{}: {}", id, e)); + continue; + } + }; + + // Validate the converted spec + if let Err(e) = validate_server_spec(&unified_spec) { + log::warn!("Skip invalid MCP server '{}' after conversion: {}", id, e); + errors.push(format!("{}: {}", id, e)); + continue; + } + + if let Some(existing) = servers.get_mut(&id) { + // Existing server: just enable OpenCode app + if !existing.apps.opencode { + existing.apps.opencode = true; + changed += 1; + log::info!("MCP server '{}' enabled for OpenCode", id); + } + } else { + // New server: default to only OpenCode enabled + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: unified_spec, + apps: McpApps { + claude: false, + codex: false, + gemini: false, + opencode: true, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("Imported new MCP server '{}' from OpenCode", id); + } + } + + if !errors.is_empty() { + log::warn!( + "Import completed with {} failures: {:?}", + errors.len(), + errors + ); + } + + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_stdio_to_local() { + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" } + }); + + let result = convert_to_opencode_format(&spec).unwrap(); + assert_eq!(result["type"], "local"); + assert_eq!(result["command"][0], "npx"); + assert_eq!(result["command"][1], "-y"); + assert_eq!(result["command"][2], "@modelcontextprotocol/server-filesystem"); + assert_eq!(result["environment"]["HOME"], "/Users/test"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_sse_to_remote() { + let spec = json!({ + "type": "sse", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" } + }); + + let result = convert_to_opencode_format(&spec).unwrap(); + assert_eq!(result["type"], "remote"); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_local_to_stdio() { + let spec = json!({ + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"], + "environment": { "HOME": "/Users/test" } + }); + + let result = convert_from_opencode_format(&spec).unwrap(); + assert_eq!(result["type"], "stdio"); + assert_eq!(result["command"], "npx"); + assert_eq!(result["args"][0], "-y"); + assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem"); + assert_eq!(result["env"]["HOME"], "/Users/test"); + } + + #[test] + fn test_convert_remote_to_sse() { + let spec = json!({ + "type": "remote", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" } + }); + + let result = convert_from_opencode_format(&spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + } +} diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 3b268dbb..50fd70f3 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -553,6 +553,7 @@ command = "echo" claude: false, codex: false, // 初始未启用 gemini: false, + opencode: false, }, description: None, homepage: None, @@ -680,6 +681,7 @@ fn import_from_claude_merges_into_config() { claude: false, // 初始未启用 codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index be7aa44e..560ecdce 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -214,6 +214,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() { claude: false, codex: false, // 初始未启用 gemini: false, + opencode: false, }, description: None, homepage: None, @@ -277,6 +278,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -320,6 +322,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { claude: true, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -352,6 +355,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -483,6 +487,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, @@ -536,6 +541,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() { claude: false, codex: false, gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index c0828f03..32884892 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -74,6 +74,7 @@ command = "say" claude: false, codex: true, // 启用 Codex gemini: false, + opencode: false, }, description: None, homepage: None, diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 54f0700a..6288bddd 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -88,6 +88,7 @@ command = "say" claude: false, codex: true, gemini: false, + opencode: false, }, description: None, homepage: None,