feat(mcp): add option to mirror MCP config to other app

- Add syncOtherSide parameter to upsert_mcp_server_in_config command
- Implement checkbox UI in McpFormModal for cross-app sync
- Automatically sync enabled MCP servers to both Claude and Codex when option is checked
- Add i18n support for sync option labels and hints
This commit is contained in:
Jason
2025-10-14 00:22:15 +08:00
parent 06010ff78e
commit a2aa5f8434
7 changed files with 94 additions and 19 deletions

View File

@@ -841,13 +841,46 @@ pub async fn upsert_mcp_server_in_config(
app: Option<String>, app: Option<String>,
id: String, id: String,
spec: serde_json::Value, spec: serde_json::Value,
sync_other_side: Option<bool>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let mut cfg = state 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 changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec)?; let mut sync_targets: Vec<crate::app_config::AppType> = Vec::new();
let changed = crate::mcp::upsert_in_config_for(&mut cfg, &app_ty, &id, spec.clone())?;
let should_sync_current = cfg
.mcp_for(&app_ty)
.servers
.get(&id)
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if should_sync_current {
sync_targets.push(app_ty.clone());
}
if sync_other_side.unwrap_or(false) {
let other_app = match app_ty.clone() {
crate::app_config::AppType::Claude => crate::app_config::AppType::Codex,
crate::app_config::AppType::Codex => crate::app_config::AppType::Claude,
};
crate::mcp::upsert_in_config_for(&mut cfg, &other_app, &id, spec)?;
let should_sync_other = cfg
.mcp_for(&other_app)
.servers
.get(&id)
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if should_sync_other {
sync_targets.push(other_app.clone());
}
}
drop(cfg); drop(cfg);
state.save()?; state.save()?;
@@ -855,18 +888,11 @@ pub async fn upsert_mcp_server_in_config(
.config .config
.lock() .lock()
.map_err(|e| format!("获取锁失败: {}", e))?; .map_err(|e| format!("获取锁失败: {}", e))?;
let should_sync = cfg2 for app_ty_to_sync in sync_targets {
.mcp_for(&app_ty) match app_ty_to_sync {
.servers
.get(&id)
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if should_sync {
match app_ty {
crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?, crate::app_config::AppType::Claude => crate::mcp::sync_enabled_to_claude(&cfg2)?,
crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?, crate::app_config::AppType::Codex => crate::mcp::sync_enabled_to_codex(&cfg2)?,
} };
} }
Ok(changed) Ok(changed)
} }

View File

@@ -24,7 +24,11 @@ interface McpFormModalProps {
appType: AppType; appType: AppType;
editingId?: string; editingId?: string;
initialData?: McpServer; initialData?: McpServer;
onSave: (id: string, server: McpServer) => Promise<void>; onSave: (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<void>;
onClose: () => void; onClose: () => void;
existingIds?: string[]; existingIds?: string[];
onNotify?: ( onNotify?: (
@@ -113,9 +117,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState(""); const [idError, setIdError] = useState("");
const [syncOtherSide, setSyncOtherSide] = useState(false);
// 判断是否使用 TOML 格式 // 判断是否使用 TOML 格式
const useToml = appType === "codex"; const useToml = appType === "codex";
const syncTargetLabel =
appType === "claude" ? t("apps.codex") : t("apps.claude");
const syncCheckboxId = useMemo(
() => `sync-other-side-${appType}`,
[appType],
);
const wizardInitialSpec = useMemo(() => { const wizardInitialSpec = useMemo(() => {
const fallback = initialData?.server; const fallback = initialData?.server;
@@ -432,7 +443,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
} }
// 显式等待父组件保存流程 // 显式等待父组件保存流程
await onSave(trimmedId, entry); await onSave(trimmedId, entry, { syncOtherSide });
} catch (error: any) { } catch (error: any) {
const detail = extractErrorMessage(error); const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t); const mapped = translateMcpBackendError(detail, t);
@@ -655,6 +666,28 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div> </div>
)} )}
</div> </div>
{/* 双端同步选项 */}
<div className="mt-4 flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<input
id={syncCheckboxId}
type="checkbox"
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
checked={syncOtherSide}
onChange={(event) => setSyncOtherSide(event.target.checked)}
/>
<label
htmlFor={syncCheckboxId}
className="text-sm text-gray-700 dark:text-gray-300"
>
<span className="font-medium">
{t("mcp.form.syncOtherSide", { target: syncTargetLabel })}
</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
{t("mcp.form.syncOtherSideHint", { target: syncTargetLabel })}
</span>
</label>
</div>
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -135,10 +135,16 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
}); });
}; };
const handleSave = async (id: string, server: McpServer) => { const handleSave = async (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => {
try { try {
const payload: McpServer = { ...server, id }; const payload: McpServer = { ...server, id };
await window.api.upsertMcpServerInConfig(appType, id, payload); await window.api.upsertMcpServerInConfig(appType, id, payload, {
syncOtherSide: options?.syncOtherSide,
});
await reload(); await reload();
setIsFormOpen(false); setIsFormOpen(false);
setEditingId(null); setEditingId(null);

View File

@@ -298,7 +298,9 @@
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}", "jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML Configuration", "tomlConfig": "TOML Configuration",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]", "tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "Config Wizard" "useWizard": "Config Wizard",
"syncOtherSide": "Mirror to {{target}}",
"syncOtherSideHint": "Apply the same settings to {{target}}; existing entries with the same id will be overwritten."
}, },
"wizard": { "wizard": {
"title": "MCP Configuration Wizard", "title": "MCP Configuration Wizard",

View File

@@ -298,7 +298,9 @@
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}", "jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML 配置", "tomlConfig": "TOML 配置",
"tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]", "tomlPlaceholder": "type = \"stdio\"\ncommand = \"uvx\"\nargs = [\"mcp-server-fetch\"]",
"useWizard": "配置向导" "useWizard": "配置向导",
"syncOtherSide": "同步到 {{target}}",
"syncOtherSideHint": "勾选后会把当前配置同时写入 {{target}},若存在同名配置将被覆盖"
}, },
"wizard": { "wizard": {
"title": "MCP 配置向导", "title": "MCP 配置向导",

View File

@@ -354,13 +354,18 @@ export const tauriAPI = {
app: AppType = "claude", app: AppType = "claude",
id: string, id: string,
spec: McpServer, spec: McpServer,
options?: { syncOtherSide?: boolean },
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
return await invoke<boolean>("upsert_mcp_server_in_config", { const payload = {
app, app,
id, id,
spec, spec,
}); ...(options?.syncOtherSide !== undefined
? { syncOtherSide: options.syncOtherSide }
: {}),
};
return await invoke<boolean>("upsert_mcp_server_in_config", payload);
} catch (error) { } catch (error) {
console.error("写入 MCPconfig.json失败:", error); console.error("写入 MCPconfig.json失败:", error);
throw error; throw error;

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

@@ -84,6 +84,7 @@ declare global {
app: AppType | undefined, app: AppType | undefined,
id: string, id: string,
spec: McpServer, spec: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<boolean>; ) => Promise<boolean>;
deleteMcpServerInConfig: ( deleteMcpServerInConfig: (
app: AppType | undefined, app: AppType | undefined,