mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-31 11:01:36 +08:00
1c0ee0c58a
* feat(grokbuild): add backend integration * feat(grokbuild): add provider and management UI * test(grokbuild): cover configuration and integrations * fix(grokbuild): address backend review feedback * fix(grokbuild): complete UI review feedback * feat(grokbuild): add CLI lifecycle management * fix(grokbuild): align provider icon fallback * test(grokbuild): cover provider icon persistence
576 lines
20 KiB
Rust
576 lines
20 KiB
Rust
//! 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.
|
|
///
|
|
/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes —
|
|
/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades
|
|
/// the server to unauthenticated calls.
|
|
const HERMES_EXTRA_FIELDS: &[&str] = &[
|
|
"enabled",
|
|
"timeout",
|
|
"connect_timeout",
|
|
"tools",
|
|
"sampling",
|
|
"roots",
|
|
"auth",
|
|
];
|
|
|
|
// ============================================================================
|
|
// 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,
|
|
grokbuild: 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_preserves_auth_field() {
|
|
let existing = json!({
|
|
"url": "https://mcp.example.com",
|
|
"auth": "oauth",
|
|
"enabled": true
|
|
});
|
|
|
|
let new_spec = json!({
|
|
"url": "https://mcp.example.com/updated",
|
|
"headers": { "X-Trace": "abc" },
|
|
"enabled": true
|
|
});
|
|
|
|
let merged = merge_hermes_spec(&existing, &new_spec);
|
|
|
|
assert_eq!(merged["url"], "https://mcp.example.com/updated");
|
|
assert_eq!(merged["headers"]["X-Trace"], "abc");
|
|
assert_eq!(
|
|
merged["auth"], "oauth",
|
|
"auth declaration must survive CC Switch round-trip"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_convert_hermes_strips_auth_on_import() {
|
|
let spec = json!({
|
|
"url": "https://mcp.example.com",
|
|
"auth": "oauth",
|
|
"enabled": true
|
|
});
|
|
|
|
let result = convert_from_hermes_format("remote", &spec).unwrap();
|
|
assert_eq!(result["type"], "sse");
|
|
assert_eq!(result["url"], "https://mcp.example.com");
|
|
assert!(
|
|
result.get("auth").is_none(),
|
|
"auth stays Hermes-specific; stripped from unified format"
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|