双向同步与「应用没装就跳过」:CC Switch 里 MCP 同步的实际行为

2026-08-10

MCP 面板最容易被想当然的一件事,是把它当成「一份配置广播给所有工具」。但在 cc-switch 的后端里,它不是广播,而是六条互相独立的分支,每条分支前面还站着一个守卫:守卫说这个应用没装,这条分支就整条不执行——既不写,也不删,连目录都不建。

下面全部基于我们本地 clone 的 cc-switch 仓库快照 c39c903(提交日期 2026-08-10),仓库内版本号 3.19.2。我们只读源码与文档文本,没有安装也没有运行过这个桌面应用

先划清参与同步的是哪几个应用

src-tauri/src/mcp/mod.rs:14-20 声明了 6 个应用子模块:claude、codex、gemini、grokbuild、hermes、opencode。数据结构侧对得上——McpApps 结构体正好是这六个布尔字段(src-tauri/src/mcp/claude.rs:90-97),import_from_all_apps 里那个数组的长度也硬编码成 6,元素依次是 claude、codex、gemini、grokbuild、opencode、hermes(src-tauri/src/services/mcp.rs:513-520)。

有两个应用是明确出局的,而且是在不同的地方各自出局的:

  • McpService::sync_server_to_app_no_configAppType::ClaudeDesktop 只打一条日志就跳过,注释原文是 “Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping”(src-tauri/src/services/mcp.rs)。
  • project_servers_to_appAppType::OpenClaw | AppType::ClaudeDesktop 直接 return Ok(()),这两个应用不参与 MCP 投影(src-tauri/src/services/mcp.rs:530-534 区段)。
  • 深链侧也有一份:parse_mcp_apps 碰到 "openclaw" 只写一条 debug 日志,注释 “OpenClaw doesn’t support MCP, ignore silently”(src-tauri/src/deeplink/mcp.rs:171-174)。

这里顺带有两处可核实的口径差,说完就停:其一,mcp/mod.rs 的模块头注释只列了 validation、claude、codex、gemini、opencode、hermes,注释里没有 grokbuild,但下面 mod grokbuild; 确实存在(src-tauri/src/mcp/mod.rs:7-19)。其二,用户手册 docs/user-manual/zh/3-extensions/3.1-mcp.md:101-109 的开关表是 5 行,正文写「MCP 功能支持 Claude、Codex、Gemini、OpenCode 和 Hermes」;而代码里 McpApps 有 grokbuild 字段、有 mcp/grokbuild.rsimport_from_all_apps 的数组也含 grokbuild。两处不一致,以我们实读的仓库状态为准,我们不推断原因。

守卫:没装就跳过,写和删都跳

每个应用都有一个 should_sync_*_mcp() 守卫函数,判断条件各不相同:

应用守卫判断什么位置
Claude~/.claude 目录 ~/.claude.json 任一存在src-tauri/src/mcp/claude.rs:11-15
Codex~/.codex 目录存在src-tauri/src/mcp/codex.rs:16-20
Gemini~/.gemini 目录存在src-tauri/src/mcp/gemini.rs:11-15
OpenCodeOpenCode 目录存在src-tauri/src/mcp/opencode.rs:29-32
Hermes~/.hermes 目录存在src-tauri/src/mcp/hermes.rs:47-49

这张表只用来回答一个问题:同步会不会发生,取决于目标目录当时在不在,而不是取决于你在面板里的勾选状态。Claude 那行是唯一的「或」,因为它的配置可以落在目录里也可以落在单个 ~/.claude.json 上。

反直觉的地方在守卫的注释里:src-tauri/src/mcp/claude.rs:12-14 写的是「按用户偏好:此时跳过写入/删除,不创建任何文件或目录」。注意它写的是写入/删除两件事,不是只挡写入。也就是说,守卫不通过时,这条分支既不会替你新建配置文件,也不会执行本该发生的移除动作——这个语义你在只看面板的时候是完全看不出来的。

它带来的直接后果是:同一次操作在两台机器上可以走出完全不同的路径。目标目录存在的那台会落盘,不存在的那台整条分支静默跳过。要判断自己属于哪一种,唯一可靠的动作是去看目标路径在不在,而不是去看面板里勾没勾。

方向 A:DB 里勾上的项,投影进 live 配置

第一个方向是 DB → live。mcp/mod.rs:23-33 导出的 sync_enabled_to_claude / sync_enabled_to_codex / sync_enabled_to_gemini 就是干这个的:把 enabled == true 的条目投影写入对应应用的 live 配置。

筛选规则很硬:只认布尔真值,缺省一律视为 falsesrc-tauri/src/mcp/claude.rs:18-38)。所以一条 MCP 记录如果没有显式的 enabled 字段,它在这一步就不会被投影出去。这不是「默认开启」的语义。

方向 B:live 里已有的服务器,反向收进 DB

第二个方向是 live → DB,对应 import_from_* 六个函数。它的合并规则有三条,每条都值得单独记:

第一,已存在的服务器只置位,不覆盖。 导入时若这个服务器 DB 里已经有了,代码只把「对应应用」那一位置成 true,注释原文是不覆盖其他字段和应用状态(src-tauri/src/mcp/claude.rs:49-5075-81)。也就是说导入不会把你在面板里改过的参数冲掉。

第二,新导入的服务器,其余应用位一律 false。 只有来源应用那一位是 true(src-tauri/src/mcp/claude.rs:84-102src-tauri/src/mcp/gemini.rs:80-99src-tauri/src/mcp/opencode.rs:251-269)。从某个 CLI 里捞进来的服务器不会自动铺到别的 CLI 上,要铺得你自己再勾。

第三,单项失败不中止。 某一条服务器解析失败时收集错误继续处理,失败项写 warn 日志(src-tauri/src/mcp/claude.rs:68-73109-111)。所以导入成功的条数小于文件里实际有的条数是可能的,差额在 warn 日志里。

取消勾选时,反向删除是逐应用比对出来的

upsert_server 是这套机制里最值得看的一段(src-tauri/src/services/mcp.rs:19-48)。它先读旧记录的 apps,保存新记录,然后逐个比对:旧的为 true 而新的为 false 的应用,调 remove_server_from_app 从那个应用的 live 配置里删掉。claude、codex、gemini、grokbuild、opencode、hermes 六个分支写得整整齐齐,一个不落。比对完之后才把新状态同步到各个启用的应用。

delete_server 则更直接:从数据库删掉之后,从所有应用的 live 配置移除(src-tauri/src/services/mcp.rs:57-70)。

把这两段和上一节的守卫叠起来看,才能得到完整语义:取消勾选触发的是一次真实的删除动作,而这次删除同样要过守卫。守卫不通过的那个应用,这一步不会执行。

import_from_all_apps 走的是 best-effort:单个应用失败(比如某个配置文件坏了)不阻断其余应用,全部跑完后再把失败聚合成一个错误上报。注释里还留了一段实现史——历史实现逐应用 unwrap_or(0) 吞错,坏文件只会表现为「导入成功 0 个」,用户无从得知哪个应用出了问题(src-tauri/src/services/mcp.rs:507-512)。这句注释本身就是一条排查线索:如果你在旧版本上见过「导入成功 0 个」,那个 0 未必代表文件里真的没东西。

写入侧的两个防御,都在 Codex 分支

TOML 那一侧有两处专门写来挡静默失效的代码,都在 src-tauri/src/mcp/codex.rs:351-390

  • upsert_mcp_server_table 处理 mcp_servers 存在但不是表的情况(例如被写成 mcp_servers = "x"),这种结构会让 toml_edit 的 IndexMut panic。
  • remove_mcp_server_from_docas_table_like_mut 来处理 inline table,注释说明否则删除会「静默失效」。

另外,Codex 同步前会先读现有的 config.toml,若语法无效则报错,不尝试覆盖src-tauri/src/mcp/codex.rs:283)。这一条的实际含义是:你那份手写坏了的 TOML 不会被工具悄悄改写掉,但同步也就到此为止了。

自己怎么核这一段

不需要装任何东西,四个动作就能把上面的结论全部复现:

grep -n "should_sync" src-tauri/src/mcp/*.rs
grep -n "mod " src-tauri/src/mcp/mod.rs
sed -n '19,48p' src-tauri/src/services/mcp.rs
sed -n '507,520p' src-tauri/src/services/mcp.rs

第一条把所有守卫及其全部调用点列出来,你能看清守卫挡在哪些函数前面;第二条能同时看到模块头注释与 mod grokbuild; 的差异;第三条是取消勾选的六个删除分支;第四条是 best-effort 导入与那段实现史注释。想再核一遍应用集合是否六个齐全,src-tauri/src/deeplink/mcp.rs:205-227 有一个单测 enabled_apps_merge_covers_every_supported_mcp_client,断言合并后 claude、codex、gemini、grokbuild、opencode、hermes 六项全为 true。以上命令在仓库根目录执行,路径按仓库结构书写,我们没有运行过构建或测试,测试数量与断言内容均为静态阅读所得。

文档侧要对照的两处是 docs/user-manual/zh/3-extensions/3.1-mcp.md:101-109(那张 5 行的开关表)和上面的代码位置。

什么情况说明不是「守卫跳过」

排查到这一步,得能反过来排除。以下几种情形,问题多半不在守卫上:

  • 目标目录明明存在,配置却没变。 守卫的条件只是目录/文件存在,存在即通过。这时候更该看的是格式转换与校验那一层,而不是继续盯着守卫。
  • 报的是明确错误而不是「什么都没发生」。 守卫不通过的表现是静默跳过,不是报错;能看到具体错误信息(比如 TOML 语法无效)就说明流程已经走过守卫了。
  • 只有一个应用没导入进来、其它都正常。 导入侧的 import_from_all_apps 是 best-effort,单个应用失败不阻断其余(src-tauri/src/services/mcp.rs:507-512),所以只有一个应用没导入进来时,更该看那一个应用的路径或文件状态。这条容错口径只对导入侧有依据,upsert_serverdelete_server 的分支我们没有同等的语义依据,不往那边推广。
  • 导入数量对不上但没有报错。 参考单项失败不中止那条规则,差额在 warn 日志里,与守卫无关。

最后要说清一件事:这套同步会读写 ~/.claude~/.codex 这类真实 CLI 配置文件,这些是你本机上的敏感数据,同步动作是对它们的实际修改。上面所有描述都只是源码语义,不是对任何一次实际运行结果的保证,也不构成「这样配就安全」的结论。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、 src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。