tmux-python / tmux-python/libtmux-mcp
`revert` can leave a config swapped and delete every backup
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 13
- Forks
- 0
- Avg merge
- 13h 52m
- Merged PRs (30d)
- 4
Description
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
userandprojectscopes both write~/.claude.json, sorevert --scopeunwinds 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.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:
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
_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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in scripts/mcp_swap.py at _cmd_revert and review SwapEntry.seq_no, then run tests/test_mcp_swap.py::test_claude_full_revert_unwinds_both_scopes_in_lifo_order. Add coverage for the scoped and shared-file reproductions, with selected entries ordered globally by seq_no and unsafe partial reverts refused while backups are retained. The module docstring should no longer claim the Claude scopes have independent backups.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100