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,6 +314,8 @@ pub async fn switch_provider(
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
// 为避免长期可变借用,尽快获取必要数据并缩小借用范围
let provider = {
let manager = config
.get_manager_mut(&app_type)
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
@@ -324,6 +326,8 @@ pub async fn switch_provider(
.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,13 +394,24 @@ 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 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) {
if let Some(cur) = manager.providers.get_mut(&manager.current) {
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;
}
}
}
}
// 切换:从目标供应商 settings_config 写入主配置
if let Some(parent) = settings_path.parent() {
@@ -394,8 +423,18 @@ pub async fn switch_provider(
}
}
// 更新当前供应商
// 更新当前供应商(短借用范围)
{
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) {
// 若删除的是 Claude/Codex 客户端的条目,则同步一次,确保启用项从对应 live 配置中移除
let cfg2 = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
crate::mcp::sync_enabled_to_claude(&cfg2)?;
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(())
}

View File

@@ -392,6 +392,7 @@ function App() {
{isMcpOpen && (
<McpPanel
appType={activeApp}
onClose={() => setIsMcpOpen(false)}
onNotify={showNotification}
/>

View File

@@ -9,6 +9,7 @@ import { extractErrorMessage } from "../../utils/errorUtils";
import { mcpPresets } from "../../config/mcpPresets";
import McpToggle from "./McpToggle";
import { buttonStyles, cardStyles, cn } from "../../lib/styles";
import { AppType } from "../../lib/tauri-api";
interface McpPanelProps {
onClose: () => void;
@@ -17,13 +18,14 @@ interface McpPanelProps {
type: "success" | "error",
duration?: number,
) => void;
appType: AppType;
}
/**
* MCP 管理面板
* 采用与主界面一致的设计风格,右上角添加按钮,每个 MCP 占一行
*/
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, McpServer>>({});
const [loading, setLoading] = useState(true);
@@ -39,7 +41,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
const reload = async () => {
setLoading(true);
try {
const cfg = await window.api.getMcpConfig("claude");
const cfg = await window.api.getMcpConfig(appType);
setServers(cfg.servers || {});
} finally {
setLoading(false);
@@ -49,11 +51,13 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
useEffect(() => {
const setup = async () => {
try {
// 从 ~/.claude.json 导入已存在的 MCP设为 enabled=true
// Claude从 ~/.claude.json 导入已存在的 MCP设为 enabled=true
if (appType === "claude") {
await window.api.importMcpFromClaude();
}
// 读取现有 config.json 内容
const cfg = await window.api.getMcpConfig("claude");
const cfg = await window.api.getMcpConfig(appType);
const existing = cfg.servers || {};
// 将预设落库为禁用(若缺失)
@@ -64,7 +68,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
enabled: false,
source: "preset",
} as unknown as McpServer;
await window.api.upsertMcpServerInConfig("claude", p.id, seed);
await window.api.upsertMcpServerInConfig(appType, p.id, seed);
}
} catch (e) {
console.warn("MCP 初始化导入/落库失败(忽略继续)", e);
@@ -73,7 +77,8 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
}
};
setup();
}, []);
// appType 改变时重新初始化
}, [appType]);
const handleToggle = async (id: string, enabled: boolean) => {
try {
@@ -81,9 +86,9 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
if (!server) {
const preset = mcpPresets.find((p) => p.id === id);
if (!preset) return;
await window.api.upsertMcpServerInConfig("claude", id, preset.server as McpServer);
await window.api.upsertMcpServerInConfig(appType, id, preset.server as McpServer);
}
await window.api.setMcpEnabled("claude", id, enabled);
await window.api.setMcpEnabled(appType, id, enabled);
await reload();
onNotify?.(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
@@ -117,7 +122,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
message: t("mcp.confirm.deleteMessage", { id }),
onConfirm: async () => {
try {
await window.api.deleteMcpServerInConfig("claude", id);
await window.api.deleteMcpServerInConfig(appType, id);
await reload();
setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
@@ -135,7 +140,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
const handleSave = async (id: string, server: McpServer) => {
try {
await window.api.upsertMcpServerInConfig("claude", id, server);
await window.api.upsertMcpServerInConfig(appType, id, server);
await reload();
setIsFormOpen(false);
setEditingId(null);
@@ -172,7 +177,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
{/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("mcp.title")}
{t("mcp.title")} · {t(appType === "claude" ? "apps.claude" : "apps.codex")}
</h3>
<div className="flex items-center gap-3">

View File

@@ -396,6 +396,16 @@ export const tauriAPI = {
}
},
// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml
syncEnabledMcpToCodex: async (): Promise<boolean> => {
try {
return await invoke<boolean>("sync_enabled_mcp_to_codex");
} catch (error) {
console.error("同步启用 MCP 到 config.toml 失败:", error);
throw error;
}
},
importMcpFromClaude: async (): Promise<number> => {
try {
return await invoke<number>("import_mcp_from_claude");

1
src/vite-env.d.ts vendored
View File

@@ -84,6 +84,7 @@ declare global {
enabled: boolean,
) => Promise<boolean>;
syncEnabledMcpToClaude: () => Promise<boolean>;
syncEnabledMcpToCodex: () => Promise<boolean>;
importMcpFromClaude: () => Promise<number>;
testApiEndpoints: (
urls: string[],