feat(mcp): add automatic key normalization for server entries
- Add normalize_server_keys() to ensure MCP server map keys match internal id fields - Auto-normalize on all read/write operations (get, upsert, delete, import, sync) - Handle edge cases: empty/whitespace ids, key renaming, conflict resolution - Auto-save config when normalization detects changes - Apply cargo fmt for code formatting consistency This enhancement improves data integrity by automatically fixing inconsistencies between server entry keys and their id fields, especially after manual config edits.
This commit is contained in:
@@ -27,8 +27,8 @@ fn read_json_value(path: &Path) -> Result<Value, String> {
|
|||||||
}
|
}
|
||||||
let content =
|
let content =
|
||||||
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
||||||
let value: Value =
|
let value: Value = serde_json::from_str(&content)
|
||||||
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?;
|
.map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), String> {
|
|||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
.map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
let json =
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?;
|
||||||
atomic_write(path, json.as_bytes())
|
atomic_write(path, json.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +64,7 @@ pub fn read_mcp_json() -> Result<Option<String>, String> {
|
|||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let content =
|
let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?;
|
||||||
fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?;
|
|
||||||
Ok(Some(content))
|
Ok(Some(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,21 +100,24 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
let mut root = if path.exists() { read_json_value(&path)? } else { serde_json::json!({}) };
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
// 确保 mcpServers 对象存在
|
// 确保 mcpServers 对象存在
|
||||||
{
|
{
|
||||||
let obj = root.as_object_mut().ok_or_else(|| "mcp.json 根必须是对象".to_string())?;
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| "mcp.json 根必须是对象".to_string())?;
|
||||||
if !obj.contains_key("mcpServers") {
|
if !obj.contains_key("mcpServers") {
|
||||||
obj.insert("mcpServers".into(), serde_json::json!({}));
|
obj.insert("mcpServers".into(), serde_json::json!({}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let before = root.clone();
|
let before = root.clone();
|
||||||
if let Some(servers) = root
|
if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
|
||||||
.get_mut("mcpServers")
|
|
||||||
.and_then(|v| v.as_object_mut())
|
|
||||||
{
|
|
||||||
servers.insert(id.to_string(), spec);
|
servers.insert(id.to_string(), spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +188,15 @@ pub fn validate_command_in_path(cmd: &str) -> Result<bool, String> {
|
|||||||
|
|
||||||
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
|
||||||
/// 仅覆盖 mcpServers,其他字段保持不变
|
/// 仅覆盖 mcpServers,其他字段保持不变
|
||||||
pub fn set_mcp_servers_map(servers: &std::collections::HashMap<String, Value>) -> Result<(), String> {
|
pub fn set_mcp_servers_map(
|
||||||
|
servers: &std::collections::HashMap<String, Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let path = user_config_path();
|
let path = user_config_path();
|
||||||
let mut root = if path.exists() { read_json_value(&path)? } else { serde_json::json!({}) };
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
// 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范
|
||||||
let mut out: Map<String, Value> = Map::new();
|
let mut out: Map<String, Value> = Map::new();
|
||||||
|
|||||||
@@ -63,9 +63,15 @@ pub fn write_claude_config() -> Result<bool, String> {
|
|||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
if let Some(map) = obj.as_object_mut() {
|
if let Some(map) = obj.as_object_mut() {
|
||||||
let cur = map.get("primaryApiKey").and_then(|v| v.as_str()).unwrap_or("");
|
let cur = map
|
||||||
|
.get("primaryApiKey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
if cur != "any" {
|
if cur != "any" {
|
||||||
map.insert("primaryApiKey".to_string(), serde_json::Value::String("any".to_string()));
|
map.insert(
|
||||||
|
"primaryApiKey".to_string(),
|
||||||
|
serde_json::Value::String("any".to_string()),
|
||||||
|
);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,17 +66,21 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R
|
|||||||
|
|
||||||
// 读取旧内容用于回滚
|
// 读取旧内容用于回滚
|
||||||
let old_auth = if auth_path.exists() {
|
let old_auth = if auth_path.exists() {
|
||||||
Some(fs::read(&auth_path)
|
Some(
|
||||||
.map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?)
|
fs::read(&auth_path)
|
||||||
} else {
|
.map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?,
|
||||||
None
|
)
|
||||||
};
|
|
||||||
let _old_config = if config_path.exists() {
|
|
||||||
Some(fs::read(&config_path)
|
|
||||||
.map_err(|e| format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e))?)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let _old_config =
|
||||||
|
if config_path.exists() {
|
||||||
|
Some(fs::read(&config_path).map_err(|e| {
|
||||||
|
format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e)
|
||||||
|
})?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// 准备写入内容
|
// 准备写入内容
|
||||||
let cfg_text = match config_text_opt {
|
let cfg_text = match config_text_opt {
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ use tauri_plugin_dialog::DialogExt;
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::claude_plugin;
|
|
||||||
use crate::claude_mcp;
|
use crate::claude_mcp;
|
||||||
use crate::store::AppState;
|
use crate::claude_plugin;
|
||||||
use crate::codex_config;
|
use crate::codex_config;
|
||||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||||
use crate::provider::{Provider, ProviderMeta};
|
use crate::provider::{Provider, ProviderMeta};
|
||||||
use crate::speedtest;
|
use crate::speedtest;
|
||||||
|
use crate::store::AppState;
|
||||||
|
|
||||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||||
match app_type {
|
match app_type {
|
||||||
@@ -381,11 +381,7 @@ pub async fn switch_provider(
|
|||||||
let auth: Value = crate::config::read_json_file(&auth_path)?;
|
let auth: Value = crate::config::read_json_file(&auth_path)?;
|
||||||
let config_str = if config_path.exists() {
|
let config_str = if config_path.exists() {
|
||||||
std::fs::read_to_string(&config_path).map_err(|e| {
|
std::fs::read_to_string(&config_path).map_err(|e| {
|
||||||
format!(
|
format!("读取 config.toml 失败: {}: {}", config_path.display(), e)
|
||||||
"读取 config.toml 失败: {}: {}",
|
|
||||||
config_path.display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?
|
})?
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -784,7 +780,6 @@ pub async fn read_claude_mcp_config() -> Result<Option<String>, String> {
|
|||||||
claude_mcp::read_mcp_json()
|
claude_mcp::read_mcp_json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 新增或更新一个 MCP 服务器条目
|
/// 新增或更新一个 MCP 服务器条目
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
|
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
|
||||||
@@ -815,15 +810,28 @@ pub struct McpConfigResponse {
|
|||||||
|
|
||||||
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json)
|
/// 获取 MCP 配置(来自 ~/.cc-switch/config.json)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_mcp_config(state: State<'_, AppState>, app: Option<String>) -> Result<McpConfigResponse, String> {
|
pub async fn get_mcp_config(
|
||||||
let config_path = crate::config::get_app_config_path().to_string_lossy().to_string();
|
state: State<'_, AppState>,
|
||||||
let cfg = state
|
app: Option<String>,
|
||||||
|
) -> Result<McpConfigResponse, String> {
|
||||||
|
let config_path = crate::config::get_app_config_path()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let mut cfg = state
|
||||||
.config
|
.config
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
|
let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude"));
|
||||||
let servers = crate::mcp::get_servers_snapshot_for(&cfg, &app_ty);
|
let (servers, normalized) = crate::mcp::get_servers_snapshot_for(&mut cfg, &app_ty);
|
||||||
Ok(McpConfigResponse { config_path, servers })
|
let need_save = normalized > 0;
|
||||||
|
drop(cfg);
|
||||||
|
if need_save {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
|
Ok(McpConfigResponse {
|
||||||
|
config_path,
|
||||||
|
servers,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
/// 在 config.json 中新增或更新一个 MCP 服务器定义
|
||||||
@@ -894,22 +902,34 @@ pub async fn set_mcp_enabled(
|
|||||||
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json(不更改 config.json)
|
/// 手动同步:将启用的 MCP 投影到 ~/.claude.json(不更改 config.json)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
|
pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
let cfg = state
|
let mut cfg = state
|
||||||
.config
|
.config
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Claude);
|
||||||
crate::mcp::sync_enabled_to_claude(&cfg)?;
|
crate::mcp::sync_enabled_to_claude(&cfg)?;
|
||||||
|
let need_save = normalized > 0;
|
||||||
|
drop(cfg);
|
||||||
|
if need_save {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml(不更改 config.json)
|
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml(不更改 config.json)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
|
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
let cfg = state
|
let mut cfg = state
|
||||||
.config
|
.config
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||||
|
let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Codex);
|
||||||
crate::mcp::sync_enabled_to_codex(&cfg)?;
|
crate::mcp::sync_enabled_to_codex(&cfg)?;
|
||||||
|
let need_save = normalized > 0;
|
||||||
|
drop(cfg);
|
||||||
|
if need_save {
|
||||||
|
state.save()?;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,11 +118,10 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, Stri
|
|||||||
return Err(format!("文件不存在: {}", path.display()));
|
return Err(format!("文件不存在: {}", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(path)
|
let content =
|
||||||
.map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
serde_json::from_str(&content)
|
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
|
||||||
.map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写入 JSON 配置文件
|
/// 写入 JSON 配置文件
|
||||||
@@ -192,14 +191,26 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
|||||||
if path.exists() {
|
if path.exists() {
|
||||||
let _ = fs::remove_file(path);
|
let _ = fs::remove_file(path);
|
||||||
}
|
}
|
||||||
fs::rename(&tmp, path)
|
fs::rename(&tmp, path).map_err(|e| {
|
||||||
.map_err(|e| format!("原子替换失败: {} -> {}: {}", tmp.display(), path.display(), e))?;
|
format!(
|
||||||
|
"原子替换失败: {} -> {}: {}",
|
||||||
|
tmp.display(),
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
fs::rename(&tmp, path)
|
fs::rename(&tmp, path).map_err(|e| {
|
||||||
.map_err(|e| format!("原子替换失败: {} -> {}: {}", tmp.display(), path.display(), e))?;
|
format!(
|
||||||
|
"原子替换失败: {} -> {}: {}",
|
||||||
|
tmp.display(),
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
mod app_config;
|
mod app_config;
|
||||||
mod claude_plugin;
|
|
||||||
mod claude_mcp;
|
mod claude_mcp;
|
||||||
mod mcp;
|
mod claude_plugin;
|
||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
|
mod mcp;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod store;
|
|
||||||
mod speedtest;
|
mod speedtest;
|
||||||
|
mod store;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
|
|||||||
@@ -68,6 +68,97 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
|
||||||
|
let mut change_count = 0usize;
|
||||||
|
let mut renames: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
|
for (key_ref, value) in map.iter_mut() {
|
||||||
|
let key = key_ref.clone();
|
||||||
|
let Some(obj) = value.as_object_mut() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_value = obj.get("id").cloned();
|
||||||
|
|
||||||
|
let target_id: String;
|
||||||
|
|
||||||
|
match id_value {
|
||||||
|
Some(id_val) => match id_val.as_str() {
|
||||||
|
Some(id_str) => {
|
||||||
|
let trimmed = id_str.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
obj.insert("id".into(), json!(key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
target_id = key.clone();
|
||||||
|
} else {
|
||||||
|
if trimmed != id_str {
|
||||||
|
obj.insert("id".into(), json!(trimmed));
|
||||||
|
change_count += 1;
|
||||||
|
}
|
||||||
|
target_id = trimmed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
obj.insert("id".into(), json!(key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
target_id = key.clone();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
obj.insert("id".into(), json!(key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
target_id = key.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_id != key {
|
||||||
|
renames.push((key, target_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (old_key, new_key) in renames {
|
||||||
|
if old_key == new_key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if map.contains_key(&new_key) {
|
||||||
|
log::warn!(
|
||||||
|
"MCP 条目 '{}' 的内部 id '{}' 与现有键冲突,回退为原键",
|
||||||
|
old_key,
|
||||||
|
new_key
|
||||||
|
);
|
||||||
|
if let Some(value) = map.get_mut(&old_key) {
|
||||||
|
if let Some(obj) = value.as_object_mut() {
|
||||||
|
if obj
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s != old_key)
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
obj.insert("id".into(), json!(old_key.clone()));
|
||||||
|
change_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(mut value) = map.remove(&old_key) {
|
||||||
|
if let Some(obj) = value.as_object_mut() {
|
||||||
|
obj.insert("id".into(), json!(new_key.clone()));
|
||||||
|
}
|
||||||
|
log::info!("MCP 条目键名已自动修复: '{}' -> '{}'", old_key, new_key);
|
||||||
|
map.insert(new_key, value);
|
||||||
|
change_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
change_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
|
||||||
|
let servers = &mut config.mcp_for_mut(app).servers;
|
||||||
|
normalize_server_keys(servers)
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_server_spec(entry: &Value) -> Result<Value, String> {
|
fn extract_server_spec(entry: &Value) -> Result<Value, String> {
|
||||||
let obj = entry
|
let obj = entry
|
||||||
.as_object()
|
.as_object()
|
||||||
@@ -106,7 +197,11 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashMap<String, Value> {
|
pub fn get_servers_snapshot_for(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app: &AppType,
|
||||||
|
) -> (HashMap<String, Value>, usize) {
|
||||||
|
let normalized = normalize_servers_for(config, app);
|
||||||
let mut snapshot = config.mcp_for(app).servers.clone();
|
let mut snapshot = config.mcp_for(app).servers.clone();
|
||||||
snapshot.retain(|id, value| {
|
snapshot.retain(|id, value| {
|
||||||
let Some(obj) = value.as_object_mut() else {
|
let Some(obj) = value.as_object_mut() else {
|
||||||
@@ -124,7 +219,7 @@ pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
snapshot
|
(snapshot, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_in_config_for(
|
pub fn upsert_in_config_for(
|
||||||
@@ -136,6 +231,7 @@ pub fn upsert_in_config_for(
|
|||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err("MCP 服务器 ID 不能为空".into());
|
||||||
}
|
}
|
||||||
|
normalize_servers_for(config, app);
|
||||||
validate_mcp_entry(&spec)?;
|
validate_mcp_entry(&spec)?;
|
||||||
|
|
||||||
let mut entry_obj = spec
|
let mut entry_obj = spec
|
||||||
@@ -165,10 +261,15 @@ pub fn upsert_in_config_for(
|
|||||||
Ok(before.is_none())
|
Ok(before.is_none())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_in_config_for(config: &mut MultiAppConfig, app: &AppType, id: &str) -> Result<bool, String> {
|
pub fn delete_in_config_for(
|
||||||
|
config: &mut MultiAppConfig,
|
||||||
|
app: &AppType,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err("MCP 服务器 ID 不能为空".into());
|
||||||
}
|
}
|
||||||
|
normalize_servers_for(config, app);
|
||||||
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
|
||||||
Ok(existed)
|
Ok(existed)
|
||||||
}
|
}
|
||||||
@@ -183,9 +284,13 @@ pub fn set_enabled_and_sync_for(
|
|||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err("MCP 服务器 ID 不能为空".into());
|
return Err("MCP 服务器 ID 不能为空".into());
|
||||||
}
|
}
|
||||||
|
normalize_servers_for(config, app);
|
||||||
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
|
||||||
// 写入 enabled 字段
|
// 写入 enabled 字段
|
||||||
let mut obj = spec.as_object().cloned().ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?;
|
let mut obj = spec
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?;
|
||||||
obj.insert("enabled".into(), json!(enabled));
|
obj.insert("enabled".into(), json!(enabled));
|
||||||
*spec = Value::Object(obj);
|
*spec = Value::Object(obj);
|
||||||
} else {
|
} else {
|
||||||
@@ -218,10 +323,13 @@ pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> {
|
|||||||
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String> {
|
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String> {
|
||||||
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
let text_opt = crate::claude_mcp::read_mcp_json()?;
|
||||||
let Some(text) = text_opt else { return Ok(0) };
|
let Some(text) = text_opt else { return Ok(0) };
|
||||||
let v: Value = serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?;
|
let mut changed = normalize_servers_for(config, &AppType::Claude);
|
||||||
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { return Ok(0) };
|
let v: Value =
|
||||||
|
serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?;
|
||||||
|
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
|
||||||
|
return Ok(changed);
|
||||||
|
};
|
||||||
|
|
||||||
let mut changed = 0usize;
|
|
||||||
for (id, spec) in map.iter() {
|
for (id, spec) in map.iter() {
|
||||||
// 校验目标 spec
|
// 校验目标 spec
|
||||||
validate_server_spec(spec)?;
|
validate_server_spec(spec)?;
|
||||||
@@ -291,17 +399,18 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, String> {
|
|||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
let mut changed_total = normalize_servers_for(config, &AppType::Codex);
|
||||||
|
|
||||||
let root: toml::Table = toml::from_str(&text)
|
let root: toml::Table =
|
||||||
.map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?;
|
toml::from_str(&text).map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?;
|
||||||
|
|
||||||
let mut changed_total = 0usize;
|
|
||||||
|
|
||||||
// helper:处理一组 servers 表
|
// helper:处理一组 servers 表
|
||||||
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
|
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
|
||||||
let mut changed = 0usize;
|
let mut changed = 0usize;
|
||||||
for (id, entry_val) in servers_tbl.iter() {
|
for (id, entry_val) in servers_tbl.iter() {
|
||||||
let Some(entry_tbl) = entry_val.as_table() else { continue };
|
let Some(entry_tbl) = entry_val.as_table() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
// type 缺省为 stdio
|
// type 缺省为 stdio
|
||||||
let typ = entry_tbl
|
let typ = entry_tbl
|
||||||
@@ -472,10 +581,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers)
|
// 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers)
|
||||||
let prefer_mcp_servers = root
|
let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none();
|
||||||
.get("mcp_servers")
|
|
||||||
.is_some()
|
|
||||||
|| root.get("mcp").is_none();
|
|
||||||
if enabled.is_empty() {
|
if enabled.is_empty() {
|
||||||
// 无启用项:移除两种节点
|
// 无启用项:移除两种节点
|
||||||
// 清除 mcp.servers,但保留其他 mcp 字段
|
// 清除 mcp.servers,但保留其他 mcp 字段
|
||||||
@@ -502,10 +608,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> {
|
|||||||
let mut s = TomlTable::new();
|
let mut s = TomlTable::new();
|
||||||
|
|
||||||
// 类型(缺省视为 stdio)
|
// 类型(缺省视为 stdio)
|
||||||
let typ = spec
|
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||||
.get("type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("stdio");
|
|
||||||
s.insert("type".into(), TomlValue::String(typ.to_string()));
|
s.insert("type".into(), TomlValue::String(typ.to_string()));
|
||||||
|
|
||||||
match typ {
|
match typ {
|
||||||
|
|||||||
Reference in New Issue
Block a user