mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
576ff53a75
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
200 lines
6.3 KiB
Rust
200 lines
6.3 KiB
Rust
//! MCP server import from deep link
|
|
//!
|
|
//! Handles batch import of MCP server configurations via ccswitch:// URLs.
|
|
|
|
use super::utils::decode_base64_param;
|
|
use super::DeepLinkImportRequest;
|
|
use crate::app_config::{McpApps, McpServer};
|
|
use crate::error::AppError;
|
|
use crate::services::McpService;
|
|
use crate::store::AppState;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
/// MCP import result
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct McpImportResult {
|
|
/// Number of successfully imported MCP servers
|
|
pub imported_count: usize,
|
|
/// IDs of successfully imported MCP servers
|
|
pub imported_ids: Vec<String>,
|
|
/// Failed imports with error messages
|
|
pub failed: Vec<McpImportError>,
|
|
}
|
|
|
|
/// MCP import error
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct McpImportError {
|
|
/// MCP server ID
|
|
pub id: String,
|
|
/// Error message
|
|
pub error: String,
|
|
}
|
|
|
|
/// Import MCP servers from deep link request
|
|
///
|
|
/// This function handles batch import of MCP servers from standard MCP JSON format.
|
|
/// If a server already exists, only the apps flags are merged (existing config preserved).
|
|
pub fn import_mcp_from_deeplink(
|
|
state: &AppState,
|
|
request: DeepLinkImportRequest,
|
|
) -> Result<McpImportResult, AppError> {
|
|
// Verify this is an MCP request
|
|
if request.resource != "mcp" {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Expected mcp resource, got '{}'",
|
|
request.resource
|
|
)));
|
|
}
|
|
|
|
// Extract and validate apps parameter
|
|
let apps_str = request
|
|
.apps
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?;
|
|
|
|
// Parse apps into McpApps struct
|
|
let target_apps = parse_mcp_apps(apps_str)?;
|
|
|
|
// Extract config
|
|
let config_b64 = request
|
|
.config
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?;
|
|
|
|
// Decode Base64 config
|
|
let decoded = decode_base64_param("config", config_b64)?;
|
|
|
|
let config_str = String::from_utf8(decoded)
|
|
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?;
|
|
|
|
// Parse JSON
|
|
let config_json: Value = serde_json::from_str(&config_str)
|
|
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON in MCP config: {e}")))?;
|
|
|
|
// Extract mcpServers object
|
|
let mcp_servers = config_json
|
|
.get("mcpServers")
|
|
.and_then(|v| v.as_object())
|
|
.ok_or_else(|| {
|
|
AppError::InvalidInput("MCP config must contain 'mcpServers' object".to_string())
|
|
})?;
|
|
|
|
if mcp_servers.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"No MCP servers found in config".to_string(),
|
|
));
|
|
}
|
|
|
|
// Get existing servers to check for duplicates
|
|
let existing_servers = state.db.get_all_mcp_servers()?;
|
|
|
|
// Import each MCP server
|
|
let mut imported_ids = Vec::new();
|
|
let mut failed = Vec::new();
|
|
|
|
for (id, server_spec) in mcp_servers.iter() {
|
|
// Check if server already exists
|
|
let server = if let Some(existing) = existing_servers.get(id) {
|
|
// Server exists - merge apps only, keep other fields unchanged
|
|
log::info!("MCP server '{id}' already exists, merging apps only");
|
|
|
|
let mut merged_apps = existing.apps.clone();
|
|
// Merge new apps into existing apps
|
|
if target_apps.claude {
|
|
merged_apps.claude = true;
|
|
}
|
|
if target_apps.codex {
|
|
merged_apps.codex = true;
|
|
}
|
|
if target_apps.gemini {
|
|
merged_apps.gemini = true;
|
|
}
|
|
|
|
McpServer {
|
|
id: existing.id.clone(),
|
|
name: existing.name.clone(),
|
|
server: existing.server.clone(), // Keep existing server config
|
|
apps: merged_apps, // Merged apps
|
|
description: existing.description.clone(),
|
|
homepage: existing.homepage.clone(),
|
|
docs: existing.docs.clone(),
|
|
tags: existing.tags.clone(),
|
|
}
|
|
} else {
|
|
// New server - create with provided config
|
|
log::info!("Creating new MCP server: {id}");
|
|
McpServer {
|
|
id: id.clone(),
|
|
name: id.clone(),
|
|
server: server_spec.clone(),
|
|
apps: target_apps.clone(),
|
|
description: None,
|
|
homepage: None,
|
|
docs: None,
|
|
tags: vec!["imported".to_string()],
|
|
}
|
|
};
|
|
|
|
match McpService::upsert_server(state, server) {
|
|
Ok(_) => {
|
|
imported_ids.push(id.clone());
|
|
log::info!("Successfully imported/updated MCP server: {id}");
|
|
}
|
|
Err(e) => {
|
|
failed.push(McpImportError {
|
|
id: id.clone(),
|
|
error: format!("{e}"),
|
|
});
|
|
log::warn!("Failed to import MCP server '{id}': {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(McpImportResult {
|
|
imported_count: imported_ids.len(),
|
|
imported_ids,
|
|
failed,
|
|
})
|
|
}
|
|
|
|
/// Parse apps string into McpApps struct
|
|
pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
|
|
let mut apps = McpApps {
|
|
claude: false,
|
|
codex: false,
|
|
gemini: false,
|
|
opencode: false,
|
|
hermes: false,
|
|
};
|
|
|
|
for app in apps_str.split(',') {
|
|
match app.trim() {
|
|
"claude" => apps.claude = true,
|
|
"codex" => apps.codex = true,
|
|
"gemini" => apps.gemini = true,
|
|
"opencode" => apps.opencode = true,
|
|
"openclaw" => {
|
|
// OpenClaw doesn't support MCP, ignore silently
|
|
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
|
|
}
|
|
"hermes" => apps.hermes = true,
|
|
other => {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"Invalid app in 'apps': {other}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
if apps.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"At least one app must be specified in 'apps'".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(apps)
|
|
}
|