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>,
id: String,
spec: serde_json::Value,
sync_other_side: Option<bool>,
) -> Result<bool, String> {
let mut cfg = state
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
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);
state.save()?;
@@ -855,18 +888,11 @@ pub async fn upsert_mcp_server_in_config(
.config
.lock()
.map_err(|e| format!("获取锁失败: {}", e))?;
let should_sync = cfg2
.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 {
match app_ty {
for app_ty_to_sync in sync_targets {
match app_ty_to_sync {
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(changed)
}

View File

@@ -24,7 +24,11 @@ interface McpFormModalProps {
appType: AppType;
editingId?: string;
initialData?: McpServer;
onSave: (id: string, server: McpServer) => Promise<void>;
onSave: (
id: string,
server: McpServer,
options?: { syncOtherSide?: boolean },
) => Promise<void>;
onClose: () => void;
existingIds?: string[];
onNotify?: (
@@ -113,9 +117,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const [saving, setSaving] = useState(false);
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [idError, setIdError] = useState("");
const [syncOtherSide, setSyncOtherSide] = useState(false);
// 判断是否使用 TOML 格式
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 fallback = initialData?.server;
@@ -432,7 +443,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
// 显式等待父组件保存流程
await onSave(trimmedId, entry);
await onSave(trimmedId, entry, { syncOtherSide });
} catch (error: any) {
const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t);
@@ -655,6 +666,28 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</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>
{/* 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 {
const payload: McpServer = { ...server, id };
await window.api.upsertMcpServerInConfig(appType, id, payload);
await window.api.upsertMcpServerInConfig(appType, id, payload, {
syncOtherSide: options?.syncOtherSide,
});
await reload();
setIsFormOpen(false);
setEditingId(null);

View File

@@ -298,7 +298,9 @@
"jsonPlaceholder": "{\n \"type\": \"stdio\",\n \"command\": \"uvx\",\n \"args\": [\"mcp-server-fetch\"]\n}",
"tomlConfig": "TOML Configuration",
"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": {
"title": "MCP Configuration Wizard",

View File

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

View File

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

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

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