feat(mcp): app-aware MCP panel and Codex MCP sync to config.toml

- Make MCP panel app-aware; pass appType from App and call APIs with current app
- Show active app in title: “MCP Management · Claude Code/Codex”
- Add sync_enabled_to_codex: project enabled servers from SSOT to ~/.codex/config.toml as [mcp.servers.*]
- Sync on enable/disable, delete, and provider switch (post live write)
- Add Tauri command sync_enabled_mcp_to_codex and expose window.api.syncEnabledMcpToCodex()
- Fix Rust borrow scopes in switch_provider to avoid E0502
- Add TS declarations for new Codex sync API
This commit is contained in:
Jason
2025-10-10 12:35:02 +08:00
parent 7f1131dfae
commit 428369cae0
7 changed files with 226 additions and 37 deletions

View File

@@ -314,16 +314,20 @@ pub async fn switch_provider(
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 为避免长期可变借用,尽快获取必要数据并缩小借用范围
let provider = {
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
// 检查供应商是否存在
let provider = manager
.providers
.get(&id)
.ok_or_else(|| format!("供应商不存在: {}", id))?
.clone();
provider
};
// SSOT 切换:先回填 live 配置到当前供应商,然后从内存写入目标主配置
match app_type {
@@ -331,7 +335,12 @@ pub async fn switch_provider(
use serde_json::Value;
// 回填:读取 liveauth.json + config.toml写回当前供应商 settings_config
if !manager.current.is_empty() {
if !{
let cur = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
cur.current.is_empty()
} {
let auth_path = codex_config::get_codex_auth_path();
let config_path = codex_config::get_codex_config_path();
if auth_path.exists() {
@@ -353,7 +362,16 @@ pub async fn switch_provider(
"config": config_str,
});
if let Some(cur) = manager.providers.get_mut(&manager.current) {
let cur_id2 = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(cur) = m.providers.get_mut(&cur_id2) {
cur.settings_config = live;
}
}
@@ -376,10 +394,21 @@ pub async fn switch_provider(
let settings_path = get_claude_settings_path();
// 回填:读取 live settings.json 写回当前供应商 settings_config
if settings_path.exists() && !manager.current.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
cur.settings_config = live;
if settings_path.exists() {
let cur_id = {
let m = config
.get_manager(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
m.current.clone()
};
if !cur_id.is_empty() {
if let Ok(live) = read_json_file::<serde_json::Value>(&settings_path) {
let m = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
if let Some(cur) = m.providers.get_mut(&cur_id) {
cur.settings_config = live;
}
}
}
}
@@ -394,8 +423,18 @@ pub async fn switch_provider(
}
}
// 更新当前供应商
manager.current = id;
// 更新当前供应商(短借用范围)
{
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
manager.current = id;
}
// 对 Codex切换完成且释放可变借用后再依据 SSOT 同步 MCP 到 config.toml
if let AppType::Codex = app_type {
crate::mcp::sync_enabled_to_codex(&config)?;
}
log::info!("成功切换到供应商: {}", provider.name);
@@ -752,13 +791,14 @@ pub async fn delete_mcp_server_in_config(
let existed = crate::mcp::delete_in_config_for(&mut cfg, &app_ty, &id)?;
drop(cfg);
state.save()?;
// 若删除的是 Claude 客户端的条目,则同步一次,确保启用项从 ~/.claude.json 中移除
if matches!(app_ty, crate::app_config::AppType::Claude) {
let cfg2 = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
crate::mcp::sync_enabled_to_claude(&cfg2)?;
// 若删除的是 Claude/Codex 客户端的条目,则同步一次,确保启用项从对应 live 配置中移除
let cfg2 = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
match app_ty {
crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?,
crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?,
}
Ok(existed)
}
@@ -793,6 +833,17 @@ pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result<bo
Ok(true)
}
/// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml不更改 config.json
#[tauri::command]
pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result<bool, String> {
let cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
crate::mcp::sync_enabled_to_codex(&cfg)?;
Ok(true)
}
/// 从 ~/.claude.json 导入 MCP 定义到 config.json返回变更数量
#[tauri::command]
pub async fn import_mcp_from_claude(state: State<'_, AppState>) -> Result<usize, String> {

View File

@@ -435,6 +435,7 @@ pub fn run() {
commands::delete_mcp_server_in_config,
commands::set_mcp_enabled,
commands::sync_enabled_mcp_to_claude,
commands::sync_enabled_mcp_to_codex,
commands::import_mcp_from_claude,
// ours: endpoint speed test + custom endpoint management
commands::test_api_endpoints,

View File

@@ -109,7 +109,8 @@ pub fn set_enabled_and_sync_for(
sync_enabled_to_claude(config)?;
}
AppType::Codex => {
// Codex 的 MCP 写入尚未实现TOML 结构未定),此处先跳过
// 将启用项投影到 ~/.codex/config.toml
sync_enabled_to_codex(config)?;
}
}
Ok(true)
@@ -160,3 +161,122 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, String>
}
Ok(changed)
}
/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers]
/// 策略:
/// - 读取现有 config.toml若语法无效则报错不尝试覆盖
/// - 重写根下的 `mcp` 节点(整体替换),其他节点保持不变
/// - 仅写入启用项;无启用项时移除 `mcp` 节点
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> {
use toml::{value::Value as TomlValue, Table as TomlTable};
// 1) 收集启用项Codex 维度)
let enabled = collect_enabled_servers(&config.mcp.codex);
// 2) 读取现有 config.toml 并解析为 Table允许空文件
let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
let mut root: TomlTable = if base_text.trim().is_empty() {
TomlTable::new()
} else {
toml::from_str::<TomlTable>(&base_text)
.map_err(|e| format!("解析 config.toml 失败: {}", e))?
};
// 3) 构建 mcp.servers 表
if enabled.is_empty() {
// 无启用项:清理 mcp 节点
root.remove("mcp");
} else {
let mut servers_tbl = TomlTable::new();
for (id, spec) in enabled.iter() {
let mut s = TomlTable::new();
// 类型(缺省视为 stdio
let typ = spec
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
s.insert("type".into(), TomlValue::String(typ.to_string()));
match typ {
"stdio" => {
let cmd = spec
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
s.insert("command".into(), TomlValue::String(cmd));
if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
let arr = args
.iter()
.filter_map(|x| x.as_str())
.map(|x| TomlValue::String(x.to_string()))
.collect::<Vec<_>>();
if !arr.is_empty() {
s.insert("args".into(), TomlValue::Array(arr));
}
}
if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
s.insert("cwd".into(), TomlValue::String(cwd.to_string()));
}
}
if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
let mut env_tbl = TomlTable::new();
for (k, v) in env.iter() {
if let Some(sv) = v.as_str() {
env_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
}
}
if !env_tbl.is_empty() {
s.insert("env".into(), TomlValue::Table(env_tbl));
}
}
}
"http" => {
let url = spec
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
s.insert("url".into(), TomlValue::String(url));
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
let mut h_tbl = TomlTable::new();
for (k, v) in headers.iter() {
if let Some(sv) = v.as_str() {
h_tbl.insert(k.clone(), TomlValue::String(sv.to_string()));
}
}
if !h_tbl.is_empty() {
s.insert("headers".into(), TomlValue::Table(h_tbl));
}
}
}
_ => {
// 已在 validate_mcp_spec 保障,这里忽略
}
}
servers_tbl.insert(id.clone(), TomlValue::Table(s));
}
let mut mcp_tbl = TomlTable::new();
mcp_tbl.insert("servers".into(), TomlValue::Table(servers_tbl));
// 覆盖写入 mcp 节点
root.insert("mcp".into(), TomlValue::Table(mcp_tbl));
}
// 4) 序列化并写回 config.toml仅改 TOML不触碰 auth.json
let new_text = toml::to_string(&TomlValue::Table(root))
.map_err(|e| format!("序列化 config.toml 失败: {}", e))?;
let path = crate::codex_config::get_codex_config_path();
crate::config::write_text_file(&path, &new_text)?;
Ok(())
}