mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
feat: implement Hermes MCP sync module (Phase 4)
Add mcp/hermes.rs with bidirectional MCP format conversion: - convert_to_hermes_format: strip type field, infer from command/url - convert_from_hermes_format: infer type, strip Hermes-specific fields - Merge-on-write: existing Hermes fields (tools, sampling, timeout, roots, enabled) preserved when user has customized them - update_mcp_servers_yaml: closure-based read-modify-write under write lock to prevent TOCTOU races in concurrent sync operations - 9 unit tests for format conversion and merge logic Wire up all MCP service dispatch: - Replace Hermes TODO stubs with real sync/remove calls - Remove Hermes from sync_all_enabled skip list - Enable deep link hermes MCP flag (apps.hermes = true) - Add Hermes import to import_mcp_from_apps command
This commit is contained in:
@@ -202,5 +202,6 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
|
||||
total += McpService::import_from_codex(&state).unwrap_or(0);
|
||||
total += McpService::import_from_gemini(&state).unwrap_or(0);
|
||||
total += McpService::import_from_opencode(&state).unwrap_or(0);
|
||||
total += McpService::import_from_hermes(&state).unwrap_or(0);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -180,10 +180,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
||||
// 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}"
|
||||
|
||||
@@ -715,7 +715,6 @@ fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
|
||||
// ============================================================================
|
||||
|
||||
/// Get the `mcp_servers` section as a YAML Mapping.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_mcp_servers_yaml() -> Result<serde_yaml::Mapping, AppError> {
|
||||
let config = read_hermes_config()?;
|
||||
Ok(config
|
||||
@@ -725,11 +724,23 @@ pub fn get_mcp_servers_yaml() -> Result<serde_yaml::Mapping, AppError> {
|
||||
.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<F>(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<serde_json::Value
|
||||
}
|
||||
|
||||
/// Convert a `serde_json::Value` to a `serde_yaml::Value`.
|
||||
fn json_to_yaml(json: &serde_json::Value) -> Result<serde_yaml::Value, AppError> {
|
||||
pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result<serde_yaml::Value, AppError> {
|
||||
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)
|
||||
|
||||
@@ -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<Value, AppError> {
|
||||
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<Value, AppError> {
|
||||
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<usize, AppError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<usize, AppError> {
|
||||
// 创建临时 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user