Summary
scripts/mcp_swap.py revert can finish with exit 0, print restored from … for every entry, delete all backups, and still leave the config holding the swap. It happens whenever two swap-state entries point at the same file: the older snapshot already contains the newer swap, so restoring it last re-applies what was just undone. Because the backups are then removed, the pre-swap config is unrecoverable.
Two ways to reach it, both from the documented flag surface:
- Claude's
user and project scopes both write ~/.claude.json, so revert --scope unwinds one layer at a time in whatever order they are invoked.
- Any two CLIs whose config paths resolve to one file — for example
~/.cursor/mcp.json symlinked to ~/.gemini/config/mcp_config.json, a normal dotfiles arrangement.
Reproduction
Claude's two scopes, reverted oldest-last. Everything runs under a throwaway HOME:
SB=$(mktemp -d); mkdir -p "$SB/.config"
printf '{\n "mcpServers": {\n "other": {"command": "x", "args": ["y"]}\n }\n}\n' > "$SB/.claude.json"
cp "$SB/.claude.json" "$SB/pristine.json"
run() { HOME="$SB" XDG_CONFIG_HOME="$SB/.config" XDG_STATE_HOME="$SB/.local/state" uv run scripts/mcp_swap.py "$@"; }
run use-local --repo "$PWD" --cli claude --scope project --no-preflight
run use-local --repo "$PWD" --cli claude --scope user --no-preflight
run revert --cli claude --scope project
run revert --cli claude --scope user
diff "$SB/pristine.json" "$SB/.claude.json"; ls "$SB"/.claude.json.bak.* 2>/dev/null || echo NONE
The second trigger needs no --scope. Point two CLIs at one file and revert them together:
ln -s "$SB/.cursor/mcp.json" "$SB/.gemini/config/mcp_config.json"
run use-local --repo "$PWD" --cli cursor --server alpha --no-preflight
run use-local --repo "$PWD" --cli agy --server beta --no-preflight
run revert --cli cursor --cli agy
Expected
revert unwinds every selected entry newest-first by seq_no, so the oldest snapshot — the only one that predates all the swaps — is restored last. A revert that cannot reach that state should refuse and keep the backups rather than report success.
Actual
Both reverts print restored from … and exit 0. ~/.claude.json still carries the libtmux entry under projects.<repo>.mcpServers, and both .bak.mcp-swap-* files are gone. The shared-file variant leaves the first CLI's server name behind in the same way.
Ordering is also not deterministic without --cli: targets is built from a set comprehension, so which CLI unwinds first depends on string-hash randomization.
Environment
Versions
- libtmux-mcp 0.1.0a20 (
v0.1.0a20)
- Python 3.14.6
- uv 0.12.1
- Linux 6.18.33.2 (WSL2)
Evidence
Claude two-scope revert, run against v0.1.0a20
$ run revert --cli claude --scope project
[claude:project] restored from <sandbox>/.claude.json.bak.mcp-swap-20260809203355-project
rc=0
$ run revert --cli claude --scope user
[claude:user] restored from <sandbox>/.claude.json.bak.mcp-swap-20260809203355-user
rc=0
$ diff pristine.json .claude.json
CORRUPT — swap still present: "libtmux"
$ ls .claude.json.bak.*
NONE — all backups deleted
Two CLIs sharing one file via symlink
$ rg -o '"(cursor|agy):user"|"target_path": "[^"]*"' state.json
"cursor:user"
"target_path": "$HOME/.cursor/mcp.json"
"agy:user"
"target_path": "$HOME/.cursor/mcp.json"
$ run revert --cli cursor --cli agy
(2 "restored from" lines, rc=0)
$ diff pristine.json .cursor/mcp.json
CORRUPT — leftover: "alpha"
$ ls .cursor/*.bak.*
NONE
Proposal
SwapEntry.seq_no already documents itself as "the primary LIFO sort key for cmd_revert", and the sort honouring it is correct — it is just applied in the wrong place. In _cmd_revert the entries are filtered per CLI and per scope before being sorted, so seq_no only ever orders entries within one CLI.
Sort the whole selected set by seq_no descending once, then iterate, rather than sorting inside the per-CLI loop. That fixes the shared-file case directly and makes the iteration order deterministic without needing to sort targets separately.
That leaves the scoped case, where the user has asked to unwind only part of a stack. Restoring an older snapshot while a newer one for the same target_path is still outstanding cannot be done safely from a whole-file backup. revert --scope should refuse when a higher-seq_no entry shares its target_path, naming the entry that has to go first.
The module docstring's claim that "Both Claude scopes can coexist with independent backups" should go with it: the backups are stacked snapshots of one file, not independent.
References
Summary
scripts/mcp_swap.py revertcan finish with exit 0, printrestored from …for every entry, delete all backups, and still leave the config holding the swap. It happens whenever two swap-state entries point at the same file: the older snapshot already contains the newer swap, so restoring it last re-applies what was just undone. Because the backups are then removed, the pre-swap config is unrecoverable.Two ways to reach it, both from the documented flag surface:
userandprojectscopes both write~/.claude.json, sorevert --scopeunwinds one layer at a time in whatever order they are invoked.~/.cursor/mcp.jsonsymlinked to~/.gemini/config/mcp_config.json, a normal dotfiles arrangement.Reproduction
Claude's two scopes, reverted oldest-last. Everything runs under a throwaway
HOME:The second trigger needs no
--scope. Point two CLIs at one file and revert them together:Expected
revertunwinds every selected entry newest-first byseq_no, so the oldest snapshot — the only one that predates all the swaps — is restored last. A revert that cannot reach that state should refuse and keep the backups rather than report success.Actual
Both reverts print
restored from …and exit 0.~/.claude.jsonstill carries thelibtmuxentry underprojects.<repo>.mcpServers, and both.bak.mcp-swap-*files are gone. The shared-file variant leaves the first CLI's server name behind in the same way.Ordering is also not deterministic without
--cli:targetsis built from a set comprehension, so which CLI unwinds first depends on string-hash randomization.Environment
Versions
v0.1.0a20)Evidence
Claude two-scope revert, run against
v0.1.0a20Two CLIs sharing one file via symlink
Proposal
SwapEntry.seq_noalready documents itself as "the primary LIFO sort key forcmd_revert", and the sort honouring it is correct — it is just applied in the wrong place. In_cmd_revertthe entries are filtered per CLI and per scope before being sorted, soseq_noonly ever orders entries within one CLI.Sort the whole selected set by
seq_nodescending once, then iterate, rather than sorting inside the per-CLI loop. That fixes the shared-file case directly and makes the iteration order deterministic without needing to sorttargetsseparately.That leaves the scoped case, where the user has asked to unwind only part of a stack. Restoring an older snapshot while a newer one for the same
target_pathis still outstanding cannot be done safely from a whole-file backup.revert --scopeshould refuse when a higher-seq_noentry shares itstarget_path, naming the entry that has to go first.The module docstring's claim that "Both Claude scopes can coexist with independent backups" should go with it: the backups are stacked snapshots of one file, not independent.
References
_cmd_revertper-CLI filter and sort atv0.1.0a20targetsbuilt from a set comprehension atv0.1.0a20SwapEntry.seq_no, which documents the global LIFO contracttests/test_mcp_swap.py::test_claude_full_revert_unwinds_both_scopes_in_lifo_ordercovers the barerevertpath; neither the scoped sequence nor the shared-file case is covered