diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 8963584c8..5d2558e91 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -202,5 +202,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result Result { // OpenClaw doesn't support MCP, ignore silently log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter"); } - "hermes" => { - // TODO: Hermes MCP sync not yet implemented, ignore silently for now - log::debug!("Hermes MCP sync not yet implemented, ignoring in apps parameter"); - } + "hermes" => apps.hermes = true, other => { return Err(AppError::InvalidInput(format!( "Invalid app in 'apps': {other}" diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 4957ddf8d..49a74812a 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -715,7 +715,6 @@ fn scan_hermes_health_internal(content: &str) -> Vec { // ============================================================================ /// Get the `mcp_servers` section as a YAML Mapping. -#[allow(dead_code)] pub fn get_mcp_servers_yaml() -> Result { let config = read_hermes_config()?; Ok(config @@ -725,11 +724,23 @@ pub fn get_mcp_servers_yaml() -> Result { .unwrap_or_default()) } -/// Set the `mcp_servers` section. -#[allow(dead_code)] -pub fn set_mcp_servers_yaml(servers: &serde_yaml::Mapping) -> Result<(), AppError> { - let value = serde_yaml::Value::Mapping(servers.clone()); - write_yaml_section_to_config("mcp_servers", &value)?; +/// Atomically read-modify-write the `mcp_servers` section under the write lock. +/// +/// Prevents TOCTOU races when multiple sync operations run concurrently. +pub fn update_mcp_servers_yaml(updater: F) -> Result<(), AppError> +where + F: FnOnce(&mut serde_yaml::Mapping) -> Result<(), AppError>, +{ + let _guard = hermes_write_lock().lock()?; + let config = read_hermes_config()?; + let mut servers = config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + updater(&mut servers)?; + let value = serde_yaml::Value::Mapping(servers); + write_yaml_section_to_config_locked("mcp_servers", &value)?; Ok(()) } @@ -748,7 +759,7 @@ pub(crate) fn yaml_to_json(yaml: &serde_yaml::Value) -> Result Result { +pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result { let json_str = serde_json::to_string(json) .map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?; serde_yaml::from_str(&json_str) diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs new file mode 100644 index 000000000..69279b765 --- /dev/null +++ b/src-tauri/src/mcp/hermes.rs @@ -0,0 +1,531 @@ +//! Hermes MCP sync and import module +//! +//! Handles conversion between CC Switch unified MCP format and Hermes config.yaml format. +//! +//! ## Format mapping +//! +//! | CC Switch unified (JSON) | Hermes config.yaml (YAML) | +//! |-------------------------------------------------|---------------------------------| +//! | `{"type":"stdio","command":"npx","args":[...],"env":{}}` | `command: npx`, `args: [...]`, `env: {}` | +//! | `{"type":"sse"/"http","url":"...","headers":{}}` | `url: "..."`, `headers: {}` | +//! +//! Key differences from Claude format: +//! - Hermes has NO explicit `type` field -- it infers stdio (has `command`) vs HTTP (has `url`) +//! - Hermes has extra fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling` +//! - These Hermes-specific fields are preserved on merge-on-write and stripped on import + +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::app_config::{McpApps, McpServer, MultiAppConfig}; +use crate::error::AppError; +use crate::hermes_config; + +use super::validation::validate_server_spec; + +/// Hermes-specific fields preserved on merge-on-write, stripped on import. +/// Update this list when Hermes adds new per-server config fields. +const HERMES_EXTRA_FIELDS: &[&str] = &[ + "enabled", + "timeout", + "connect_timeout", + "tools", + "sampling", + "roots", +]; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Check if Hermes MCP sync should proceed +fn should_sync_hermes_mcp() -> bool { + hermes_config::get_hermes_dir().exists() +} + +// ============================================================================ +// Format Conversion: CC Switch -> Hermes +// ============================================================================ + +/// Convert CC Switch unified format to Hermes format +/// +/// Conversion rules: +/// - `stdio`: output `command`, `args`, `env` (strip `type` field) +/// - `sse`/`http`: output `url`, `headers` (strip `type` field) +/// - Always add `enabled: true` +fn convert_to_hermes_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" => { + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } + "sse" | "http" => { + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + 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 MCP type: {typ}"))); + } + } + + result.insert("enabled".into(), json!(true)); + + Ok(Value::Object(result)) +} + +// ============================================================================ +// Format Conversion: Hermes -> CC Switch +// ============================================================================ + +/// Convert Hermes format to CC Switch unified format +/// +/// Conversion rules: +/// - If `command` exists: set `type: "stdio"`, extract `command`, `args`, `env` +/// - If `url` exists: set `type: "sse"`, extract `url`, `headers` +/// - Strip Hermes-specific fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling` +fn convert_from_hermes_format(id: &str, spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?; + + let mut result = serde_json::Map::new(); + + if obj.contains_key("command") { + // stdio type + result.insert("type".into(), json!("stdio")); + + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } else if obj.contains_key("url") { + // HTTP/SSE type + result.insert("type".into(), json!("sse")); + + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + 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()); + } + } + } else { + return Err(AppError::McpValidation(format!( + "Hermes MCP server '{id}' has neither 'command' nor 'url' field" + ))); + } + + // Note: Hermes-specific fields (enabled, timeout, connect_timeout, tools, sampling) + // are intentionally NOT copied -- they are stripped on import. + + Ok(Value::Object(result)) +} + +// ============================================================================ +// Public API: Sync Functions +// ============================================================================ + +/// Sync a single MCP server to Hermes live config (merge-on-write) +/// +/// Strategy: +/// 1. Read existing mcp_servers from config.yaml +/// 2. If server already exists, merge: keep Hermes-specific fields, overwrite core fields +/// 3. Set `enabled: true` +/// 4. Write back +pub fn sync_single_server_to_hermes( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let hermes_spec = convert_to_hermes_format(server_spec)?; + let id_owned = id.to_string(); + + hermes_config::update_mcp_servers_yaml(|servers| { + let id_yaml = serde_yaml::Value::String(id_owned.clone()); + + let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) { + let existing_json = hermes_config::yaml_to_json(existing_yaml)?; + merge_hermes_spec(&existing_json, &hermes_spec) + } else { + hermes_spec.clone() + }; + + let merged_yaml_value = hermes_config::json_to_yaml(&merged_json)?; + servers.insert(id_yaml, merged_yaml_value); + Ok(()) + }) +} + +/// Merge new spec into existing Hermes spec, preserving Hermes-specific fields. +/// +/// Core fields (command, args, env, url, headers) come from `new_spec`. +/// Hermes-specific fields (enabled, tools, sampling, etc.) are kept from +/// `existing` — this prevents CC Switch from overwriting user customizations. +fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value { + let mut result = serde_json::Map::new(); + + // Copy Hermes-specific fields from existing config + if let Some(existing_obj) = existing.as_object() { + for &field in HERMES_EXTRA_FIELDS { + if let Some(val) = existing_obj.get(field) { + result.insert(field.to_string(), val.clone()); + } + } + } + + // Overwrite with core fields from new spec; for Hermes-specific fields, + // only apply from new_spec if existing didn't already have them + if let Some(new_obj) = new_spec.as_object() { + for (key, val) in new_obj { + if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) { + continue; // Existing Hermes-specific field takes precedence + } + result.insert(key.clone(), val.clone()); + } + } + + Value::Object(result) +} + +/// Remove a single MCP server from Hermes live config +pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> { + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let id_owned = id.to_string(); + hermes_config::update_mcp_servers_yaml(|servers| { + servers.remove(serde_yaml::Value::String(id_owned.clone())); + Ok(()) + }) +} + +/// Import MCP servers from Hermes config to unified structure +/// +/// Existing servers will have Hermes app enabled without overwriting other fields. +pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result { + let yaml_map = hermes_config::get_mcp_servers_yaml()?; + if yaml_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 (key, spec_yaml) in &yaml_map { + let id = match key.as_str() { + Some(s) => s.to_string(), + None => { + log::warn!("Skip Hermes MCP server with non-string key"); + continue; + } + }; + + // Convert YAML value to JSON + let spec_json = match hermes_config::yaml_to_json(spec_yaml) { + Ok(j) => j, + Err(e) => { + log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + // Convert from Hermes format to unified format + let unified_spec = match convert_from_hermes_format(&id, &spec_json) { + Ok(s) => s, + Err(e) => { + log::warn!("Skip invalid Hermes 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 '{id}' after conversion: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(&id) { + // Existing server: just enable Hermes app + if !existing.apps.hermes { + existing.apps.hermes = true; + changed += 1; + log::info!("MCP server '{id}' enabled for Hermes"); + } + } else { + // New server: default to only Hermes enabled + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: unified_spec, + apps: McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("Imported new MCP server '{id}' from Hermes"); + } + } + + if !errors.is_empty() { + log::warn!( + "Import completed with {} failures: {:?}", + errors.len(), + errors + ); + } + + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================== + // convert_to_hermes_format tests + // ======================================================================== + + #[test] + fn test_convert_stdio_to_hermes() { + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" } + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + // No type field in Hermes format + assert!(result.get("type").is_none()); + 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"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_http_to_hermes() { + let spec = json!({ + "type": "sse", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" } + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_http_type_to_hermes() { + let spec = json!({ + "type": "http", + "url": "https://example.com/mcp" + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_stdio_empty_env_to_hermes() { + let spec = json!({ + "type": "stdio", + "command": "node", + "args": [], + "env": {} + }); + + let result = convert_to_hermes_format(&spec).unwrap(); + assert_eq!(result["command"], "node"); + // Empty args and env should be omitted + assert!(result.get("args").is_none()); + assert!(result.get("env").is_none()); + assert_eq!(result["enabled"], true); + } + + #[test] + fn test_convert_unknown_type_to_hermes_fails() { + let spec = json!({ "type": "grpc", "command": "foo" }); + assert!(convert_to_hermes_format(&spec).is_err()); + } + + // ======================================================================== + // convert_from_hermes_format tests + // ======================================================================== + + #[test] + fn test_convert_hermes_stdio_to_unified() { + let spec = json!({ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" }, + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + + let result = convert_from_hermes_format("filesystem", &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"); + // Hermes-specific fields should be stripped + assert!(result.get("enabled").is_none()); + assert!(result.get("timeout").is_none()); + assert!(result.get("connect_timeout").is_none()); + assert!(result.get("tools").is_none()); + assert!(result.get("sampling").is_none()); + } + + #[test] + fn test_convert_hermes_http_to_unified() { + let spec = json!({ + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" }, + "enabled": true, + "timeout": 60 + }); + + let result = convert_from_hermes_format("remote-server", &spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + // Hermes-specific fields should be stripped + assert!(result.get("enabled").is_none()); + assert!(result.get("timeout").is_none()); + } + + #[test] + fn test_convert_hermes_no_command_no_url_fails() { + let spec = json!({ "enabled": true, "timeout": 30 }); + assert!(convert_from_hermes_format("bad-server", &spec).is_err()); + } + + // ======================================================================== + // Merge-on-write tests + // ======================================================================== + + #[test] + fn test_merge_preserves_hermes_specific_fields() { + let existing = json!({ + "command": "old-cmd", + "args": ["old-arg"], + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + + let new_spec = json!({ + "command": "new-cmd", + "args": ["new-arg"], + "env": { "KEY": "value" }, + "enabled": true + }); + + let merged = merge_hermes_spec(&existing, &new_spec); + + // Core fields should be overwritten + assert_eq!(merged["command"], "new-cmd"); + assert_eq!(merged["args"][0], "new-arg"); + assert_eq!(merged["env"]["KEY"], "value"); + + // Hermes-specific fields should be preserved from existing + assert_eq!(merged["timeout"], 30); + assert_eq!(merged["connect_timeout"], 10); + assert_eq!(merged["tools"]["include"][0], "read_file"); + assert_eq!(merged["sampling"]["enabled"], true); + assert_eq!(merged["enabled"], true); + } + + #[test] + fn test_merge_new_server_no_existing_extra_fields() { + let existing = json!({ + "command": "old-cmd" + }); + + let new_spec = json!({ + "command": "new-cmd", + "args": ["arg1"], + "enabled": true + }); + + let merged = merge_hermes_spec(&existing, &new_spec); + assert_eq!(merged["command"], "new-cmd"); + assert_eq!(merged["args"][0], "arg1"); + assert_eq!(merged["enabled"], true); + // No extra fields to preserve + assert!(merged.get("timeout").is_none()); + } +} diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 2f2b7f424..c411ac467 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -9,10 +9,12 @@ //! - `codex` - Codex MCP 同步和导入(含 TOML 转换) //! - `gemini` - Gemini MCP 同步和导入 //! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换) +//! - `hermes` - Hermes MCP 同步和导入 mod claude; mod codex; mod gemini; +mod hermes; mod opencode; mod validation; @@ -28,6 +30,7 @@ pub use gemini::{ import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini, sync_single_server_to_gemini, }; +pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes}; pub use opencode::{ import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode, }; diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 5b8d24fe6..37e04c1c6 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -40,6 +40,9 @@ impl McpService { if prev_apps.opencode && !server.apps.opencode { Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?; } + if prev_apps.hermes && !server.apps.hermes { + Self::remove_server_from_app(state, &server.id, &AppType::Hermes)?; + } // 同步到各个启用的应用 Self::sync_server_to_apps(state, &server)?; @@ -129,8 +132,11 @@ impl McpService { log::debug!("OpenClaw MCP support is still in development, skipping sync"); } AppType::Hermes => { - // TODO: Hermes MCP sync not yet implemented - log::debug!("Hermes MCP sync not yet implemented, skipping sync"); + mcp::sync_single_server_to_hermes( + &Default::default(), + &server.id, + &server.server, + )?; } } Ok(()) @@ -162,8 +168,7 @@ impl McpService { log::debug!("OpenClaw MCP support is still in development, skipping remove"); } AppType::Hermes => { - // TODO: Hermes MCP sync not yet implemented - log::debug!("Hermes MCP sync not yet implemented, skipping remove"); + mcp::remove_server_from_hermes(id)?; } } Ok(()) @@ -174,7 +179,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw | AppType::Hermes) { + if matches!(app, AppType::OpenClaw) { continue; } @@ -389,4 +394,42 @@ impl McpService { Ok(new_count) } + + /// 从 Hermes 导入 MCP + pub fn import_from_hermes(state: &AppState) -> Result { + // 创建临时 MultiAppConfig 用于导入 + let mut temp_config = crate::app_config::MultiAppConfig::default(); + + // 调用导入逻辑(从 mcp/hermes.rs) + let count = crate::mcp::import_from_hermes(&mut temp_config)?; + + let mut new_count = 0; + + // 如果有导入的服务器,保存到数据库 + if count > 0 { + if let Some(servers) = &temp_config.mcp.servers { + let mut existing = state.db.get_all_mcp_servers()?; + for server in servers.values() { + // 已存在:仅启用 Hermes,不覆盖其他字段(与导入模块语义保持一致) + let to_save = if let Some(existing_server) = existing.get(&server.id) { + let mut merged = existing_server.clone(); + merged.apps.hermes = true; + merged + } else { + // 真正的新服务器 + new_count += 1; + server.clone() + }; + + state.db.save_mcp_server(&to_save)?; + existing.insert(to_save.id.clone(), to_save.clone()); + + // 同步到对应应用 live 配置 + Self::sync_server_to_apps(state, &to_save)?; + } + } + } + + Ok(new_count) + } }