tmux-python / tmux-python/libtmux-mcp

`revert` can leave a config swapped and delete every backup

Open
#117 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
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 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.