diff --git a/bcc.py b/bcc.py
index 187767d..3155f2b 100644
--- a/bcc.py
+++ b/bcc.py
@@ -28,6 +28,8 @@ from PySide6.QtWidgets import (
QHeaderView,
QLabel,
QLineEdit,
+ QListWidget,
+ QListWidgetItem,
QMainWindow,
QMenu,
QMessageBox,
@@ -833,6 +835,97 @@ class RepairDialog(QDialog):
v.addWidget(btns)
+# --------------------------------------------------------------------------- #
+# Backup restore dialog: lists available backups, shows a diff of what will
+# change, and confirms before writing. Only servers are restored — all other
+# keys in the current config are preserved verbatim.
+# --------------------------------------------------------------------------- #
+class RestoreDialog(QDialog):
+ def __init__(self, parent, profile: core.Profile, full_config: dict):
+ super().__init__(parent)
+ self.setWindowTitle(f"Restore from backup — {profile.label}")
+ self.resize(700, 480)
+ self._profile = profile
+ self._full_config = full_config
+
+ v = QVBoxLayout(self)
+ v.setSpacing(8)
+
+ intro = QLabel(
+ "Select a backup to preview the change. Only mcpServers and "
+ "_disabledMcpServers are restored — all other keys in your config are preserved."
+ )
+ intro.setWordWrap(True)
+ v.addWidget(intro)
+
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+
+ # --- left: backup list ---
+ left = QWidget()
+ lv = QVBoxLayout(left)
+ lv.setContentsMargins(0, 0, 0, 0)
+ lv.addWidget(QLabel("Backups (newest first):"))
+ self.backup_list = QListWidget()
+ self.backup_list.currentItemChanged.connect(self._on_backup_selected)
+ lv.addWidget(self.backup_list, 1)
+ splitter.addWidget(left)
+
+ # --- right: diff preview ---
+ right = QWidget()
+ rv = QVBoxLayout(right)
+ rv.setContentsMargins(0, 0, 0, 0)
+ rv.addWidget(QLabel("Preview (what will change):"))
+ self.diff_view = QPlainTextEdit()
+ self.diff_view.setObjectName("diag")
+ self.diff_view.setReadOnly(True)
+ self.diff_view.setPlaceholderText("Select a backup on the left to see the diff.")
+ rv.addWidget(self.diff_view, 1)
+ splitter.addWidget(right)
+
+ splitter.setSizes([220, 460])
+ v.addWidget(splitter, 1)
+
+ self.btns = QDialogButtonBox()
+ self.restore_btn = self.btns.addButton("Restore", QDialogButtonBox.ButtonRole.AcceptRole)
+ self.restore_btn.setObjectName("primary")
+ self.restore_btn.setEnabled(False)
+ self.btns.addButton(QDialogButtonBox.StandardButton.Cancel)
+ self.btns.accepted.connect(self.accept)
+ self.btns.rejected.connect(self.reject)
+ v.addWidget(self.btns)
+
+ self._populate()
+
+ def _populate(self):
+ backups = core.list_backups(self._profile.path)
+ if not backups:
+ self.backup_list.addItem("(no backups found)")
+ return
+ for bp in backups:
+ item = QListWidgetItem(core.backup_label(bp))
+ item.setData(Qt.ItemDataRole.UserRole, str(bp))
+ self.backup_list.addItem(item)
+
+ def _on_backup_selected(self, current: QListWidgetItem | None, _prev):
+ if current is None or current.data(Qt.ItemDataRole.UserRole) is None:
+ self.diff_view.setPlainText("")
+ self.restore_btn.setEnabled(False)
+ return
+ bp = current.data(Qt.ItemDataRole.UserRole)
+ try:
+ diff = core.backup_diff(self._profile.path, bp, self._full_config)
+ except Exception as e:
+ diff = f"(error generating diff: {e})"
+ self.diff_view.setPlainText(diff)
+ self.restore_btn.setEnabled(True)
+
+ def selected_backup(self) -> str | None:
+ item = self.backup_list.currentItem()
+ if item is None:
+ return None
+ return item.data(Qt.ItemDataRole.UserRole)
+
+
# --------------------------------------------------------------------------- #
# Paste-JSON dialog
# --------------------------------------------------------------------------- #
@@ -992,6 +1085,10 @@ class MainWindow(QMainWindow):
reload_btn = QPushButton("Reload")
reload_btn.clicked.connect(lambda: self.load_profile(self.current_profile, confirm=True))
bar.addWidget(reload_btn)
+ self.restore_btn = QPushButton("Restore…")
+ self.restore_btn.setToolTip("Restore servers from a previous backup")
+ self.restore_btn.clicked.connect(self._restore_from_backup)
+ bar.addWidget(self.restore_btn)
bar.addStretch()
self.save_btn = QPushButton("Save")
self.save_btn.setObjectName("primary")
@@ -1544,6 +1641,28 @@ class MainWindow(QMainWindow):
f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply."
)
+ def _restore_from_backup(self):
+ if not self.current_profile:
+ return
+ if self.dirty and not self._confirm_discard():
+ return
+ dlg = RestoreDialog(self, self.current_profile, self.full_config)
+ if not dlg.exec():
+ return
+ bp = dlg.selected_backup()
+ if not bp:
+ return
+ try:
+ core.restore_backup(self.current_profile.path, bp, current_cfg=self.full_config)
+ except Exception as e:
+ QMessageBox.critical(self, "Restore failed", str(e))
+ return
+ # Reload from disk so the UI reflects the restored state
+ self.load_profile(self.current_profile, confirm=False)
+ self.status.setText(
+ f"Restored from {Path(bp).name} · Restart {self.current_profile.label} to apply."
+ )
+
# --- dirty / status -------------------------------------------------- #
def _mark_dirty(self):
self.dirty = True
diff --git a/bcc_core.py b/bcc_core.py
index 927681b..f32621f 100644
--- a/bcc_core.py
+++ b/bcc_core.py
@@ -14,6 +14,7 @@ in its original position.
from __future__ import annotations
import contextlib
+import difflib
import functools
import glob
import json
@@ -248,6 +249,102 @@ def write_config(path: str | os.PathLike, cfg: dict) -> Path | None:
return backup
+# --------------------------------------------------------------------------- #
+# Backup utility functions
+# --------------------------------------------------------------------------- #
+_BACKUP_TS_RE = re.compile(r"(\d{8}-\d{6})")
+
+
+def list_backups(config_path: Path | str) -> list[Path]:
+ """Return backup files for config_path, newest-first. Returns [] if none exist."""
+ p = Path(config_path)
+ bdir = p.parent / BACKUP_DIRNAME
+ if not bdir.is_dir():
+ return []
+ return sorted(bdir.glob(f"{p.stem}.*.json"), reverse=True)
+
+
+def backup_label(backup_path: Path | str) -> str:
+ """Human-readable label derived from the backup filename timestamp."""
+ m = _BACKUP_TS_RE.search(Path(backup_path).name)
+ if not m:
+ return Path(backup_path).name
+ ts = m.group(1) # e.g. "20260702-004124"
+ return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}"
+
+
+def backup_diff(
+ config_path: Path | str,
+ backup_path: Path | str,
+ current_cfg: dict | None = None,
+) -> str:
+ """
+ Unified diff showing what the current config would look like after restoring
+ the backup (servers only — non-server keys are preserved).
+
+ fromfile = current state, tofile = what will be written after restore.
+ Pass current_cfg to use an already-loaded (possibly repaired) in-memory config
+ as the baseline instead of re-reading from disk.
+ """
+ p = Path(config_path)
+ bp = Path(backup_path)
+
+ if current_cfg is None:
+ try:
+ current_cfg = load_config(p)
+ except Exception:
+ current_cfg = {}
+
+ backup_servers = extract_servers(load_config(bp))
+ after_cfg = dict(current_cfg)
+ apply_servers(after_cfg, backup_servers)
+
+ before_lines = (json.dumps(current_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
+ keepends=True
+ )
+ after_lines = (json.dumps(after_cfg, indent=2, ensure_ascii=False) + "\n").splitlines(
+ keepends=True
+ )
+
+ result = "".join(
+ difflib.unified_diff(
+ before_lines,
+ after_lines,
+ fromfile=f"current: {p.name}",
+ tofile=f"restore: {bp.name}",
+ )
+ )
+ return result or "(no differences — backup matches current servers)"
+
+
+def restore_backup(
+ config_path: Path | str,
+ backup_path: Path | str,
+ current_cfg: dict | None = None,
+) -> Path | None:
+ """
+ Restore server entries from backup_path into config_path.
+
+ Only mcpServers and _disabledMcpServers are replaced; all other keys in the
+ current config (conversation history, project state, etc.) are preserved
+ verbatim and in their original order.
+
+ Goes through write_config() so a pre-restore backup of the current file is
+ created automatically. Returns that backup path (or None if no prior file).
+ """
+ p = Path(config_path)
+ if current_cfg is None:
+ try:
+ current_cfg = load_config(p)
+ except Exception:
+ current_cfg = {}
+
+ backup_servers = extract_servers(load_config(Path(backup_path)))
+ cfg = dict(current_cfg)
+ apply_servers(cfg, backup_servers)
+ return write_config(p, cfg)
+
+
# --------------------------------------------------------------------------- #
# Paste / import parsing
# --------------------------------------------------------------------------- #
diff --git a/tests/test_core.py b/tests/test_core.py
index 253f1fd..5a8c442 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -318,3 +318,118 @@ def test_spawn_test_large_stderr_does_not_deadlock():
assert r["returncode"] == 3
# stderr is capped, not the full 64 KB
assert len(r["stderr"]) <= c._STDERR_CAP
+
+
+# --------------------------------------------------------------------------- #
+# 8. Backup restore
+# --------------------------------------------------------------------------- #
+@pytest.fixture
+def config_with_backup(tmp_path):
+ """A config file with two backups already present."""
+ cfgpath = tmp_path / "claude_desktop_config.json"
+ original = {
+ "globalShortcut": "Cmd+Shift+Space",
+ "mcpServers": {"server-v1": {"command": "node", "args": ["v1.js"]}},
+ }
+ cfgpath.write_text(json.dumps(original, indent=2))
+
+ # Create first backup (v1 state) by writing a new version
+ cfg = c.load_config(cfgpath)
+ servers = c.extract_servers(cfg)
+ servers[0] = c.ServerEntry("server-v2", {"command": "node", "args": ["v2.js"]}, True)
+ c.apply_servers(cfg, servers)
+ c.write_config(cfgpath, cfg)
+
+ return cfgpath
+
+
+def test_list_backups_returns_newest_first(config_with_backup):
+ backups = c.list_backups(config_with_backup)
+ assert len(backups) >= 1
+ # Sorted newest-first means descending lexicographic order on timestamps
+ names = [b.name for b in backups]
+ assert names == sorted(names, reverse=True)
+
+
+def test_list_backups_empty_when_no_bdir(tmp_path):
+ cfgpath = tmp_path / "claude_desktop_config.json"
+ cfgpath.write_text("{}")
+ assert c.list_backups(cfgpath) == []
+
+
+def test_backup_label_parses_timestamp(config_with_backup):
+ backup = c.list_backups(config_with_backup)[0]
+ label = c.backup_label(backup)
+ # label should look like "2026-07-02 00:41:24" (date + time, separated by two spaces)
+ assert "-" in label and ":" in label
+ assert len(label) == 20 # "YYYY-MM-DD HH:MM:SS" = 10 + 2 + 8
+
+
+def test_backup_diff_shows_server_change(config_with_backup):
+ backup = c.list_backups(config_with_backup)[0]
+ diff = c.backup_diff(config_with_backup, backup)
+ # The diff should show the transition back to v1 servers
+ assert "server-v1" in diff or "server-v2" in diff
+
+
+def test_backup_diff_no_diff_when_identical(tmp_path):
+ cfgpath = tmp_path / "cfg.json"
+ content = {"mcpServers": {"s": {"command": "node"}}}
+ cfgpath.write_text(json.dumps(content, indent=2))
+ bdir = cfgpath.parent / c.BACKUP_DIRNAME
+ bdir.mkdir(exist_ok=True)
+ import time as _time
+
+ stamp = _time.strftime("%Y%m%d-%H%M%S")
+ backup_path = bdir / f"cfg.{stamp}.json"
+ backup_path.write_text(json.dumps(content, indent=2))
+ diff = c.backup_diff(cfgpath, backup_path)
+ assert "(no differences" in diff
+
+
+def test_restore_backup_replaces_servers(config_with_backup):
+ # Get the v1 backup (only backup we have)
+ backups = c.list_backups(config_with_backup)
+ assert len(backups) >= 1
+ v1_backup = backups[0]
+
+ # Current file has server-v2; restore should bring back server-v1
+ c.restore_backup(config_with_backup, v1_backup)
+
+ restored = json.loads(config_with_backup.read_text())
+ assert "server-v1" in restored.get("mcpServers", {})
+ assert "server-v2" not in restored.get("mcpServers", {})
+
+
+def test_restore_preserves_non_server_keys(config_with_backup):
+ backups = c.list_backups(config_with_backup)
+ c.restore_backup(config_with_backup, backups[0])
+
+ restored = json.loads(config_with_backup.read_text())
+ # Non-server key must survive
+ assert restored.get("globalShortcut") == "Cmd+Shift+Space"
+
+
+def test_restore_creates_pre_restore_backup(config_with_backup):
+ before_count = len(c.list_backups(config_with_backup))
+ backups = c.list_backups(config_with_backup)
+ c.restore_backup(config_with_backup, backups[0])
+ after_count = len(c.list_backups(config_with_backup))
+ # restore_backup goes through write_config which makes a backup first
+ assert after_count == before_count + 1
+
+
+def test_restore_with_current_cfg_passed(config_with_backup):
+ # When current_cfg is passed in (the GUI's in-memory config), it should be
+ # used instead of re-reading from disk.
+ backups = c.list_backups(config_with_backup)
+ # Simulate the GUI passing a repaired in-memory version
+ in_memory_cfg = {
+ "globalShortcut": "Cmd+Shift+Space",
+ "extraKey": "preserved",
+ "mcpServers": {"server-v2": {"command": "node", "args": ["v2.js"]}},
+ }
+ c.restore_backup(config_with_backup, backups[0], current_cfg=in_memory_cfg)
+ restored = json.loads(config_with_backup.read_text())
+ assert restored.get("extraKey") == "preserved"
+ assert "server-v1" in restored.get("mcpServers", {})