diff --git a/bcc.py b/bcc.py
index 9433c61..20d46df 100644
--- a/bcc.py
+++ b/bcc.py
@@ -632,6 +632,56 @@ class ArgsEdit(QPlainTextEdit):
n += 1
+# --------------------------------------------------------------------------- #
+# Config-repair dialog: shown when a config file on disk fails strict JSON
+# parsing but the repair pipeline can salvage it. Lists what's wrong, what
+# would change, and lets the user decide. Nothing touches disk until Save.
+# --------------------------------------------------------------------------- #
+class RepairDialog(QDialog):
+ def __init__(self, parent, path, error: str, notes: list[str], preview: str):
+ super().__init__(parent)
+ self.setWindowTitle("Config file needs repair")
+ self.resize(620, 520)
+ v = QVBoxLayout(self)
+ v.setSpacing(10)
+
+ intro = QLabel(f"{path}
isn't valid JSON: {error}")
+ intro.setWordWrap(True)
+ v.addWidget(intro)
+
+ fixes_hdr = QLabel("BCC can repair it automatically. Fixes to apply:")
+ v.addWidget(fixes_hdr)
+ fixes = QLabel("\n".join(f" • {n}" for n in notes) or " • reformat as valid JSON")
+ fixes.setObjectName("muted")
+ fixes.setWordWrap(True)
+ v.addWidget(fixes)
+
+ prev_hdr = QLabel("The file would become:")
+ v.addWidget(prev_hdr)
+ box = QPlainTextEdit()
+ box.setObjectName("diag")
+ box.setReadOnly(True)
+ box.setPlainText(preview)
+ v.addWidget(box, 1)
+
+ note = QLabel(
+ "Nothing is written yet — choosing Repair loads the fixed config into the "
+ "editor. The file is only rewritten when you press Save, and the broken "
+ "original is backed up first."
+ )
+ note.setObjectName("muted")
+ note.setWordWrap(True)
+ v.addWidget(note)
+
+ btns = QDialogButtonBox()
+ ok = btns.addButton("Repair && load", QDialogButtonBox.ButtonRole.AcceptRole)
+ ok.setObjectName("primary")
+ btns.addButton(QDialogButtonBox.StandardButton.Cancel)
+ btns.accepted.connect(self.accept)
+ btns.rejected.connect(self.reject)
+ v.addWidget(btns)
+
+
# --------------------------------------------------------------------------- #
# Paste-JSON dialog
# --------------------------------------------------------------------------- #
@@ -972,15 +1022,27 @@ class MainWindow(QMainWindow):
return
if confirm and self.dirty and not self._confirm_discard():
return
+ repaired = False
try:
self.full_config = core.load_config(profile.path)
except Exception as e:
- QMessageBox.critical(
- self,
- "Could not read config",
- f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install.",
- )
- return
+ # Strict parse failed — see if the repair pipeline can salvage it,
+ # and let the user decide with full visibility of the changes.
+ try:
+ cfg, notes, preview = core.repair_config_file(profile.path)
+ except Exception:
+ QMessageBox.critical(
+ self,
+ "Could not read config",
+ f"{profile.path}\n\n{e}\n\nThe file couldn't be repaired automatically. "
+ f"Fix it by hand or pick another install.",
+ )
+ return
+ dlg = RepairDialog(self, profile.path, str(e), notes, preview)
+ if not dlg.exec():
+ return
+ self.full_config = cfg
+ repaired = True
self.current_profile = profile
self.servers = core.extract_servers(self.full_config)
self.dirty = False
@@ -988,6 +1050,12 @@ class MainWindow(QMainWindow):
self.undo_btn.setEnabled(False)
self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False)
+ if repaired:
+ self._mark_dirty()
+ self.status.setText(
+ f"{profile.path} · repaired config loaded — press Save to write the "
+ f"fix (the original file will be backed up)"
+ )
# --- table rendering ------------------------------------------------- #
def _add_row(self, table, master_idx, s):
diff --git a/bcc_core.py b/bcc_core.py
index 397fc6f..f471509 100644
--- a/bcc_core.py
+++ b/bcc_core.py
@@ -132,6 +132,33 @@ def load_config(path: str | os.PathLike) -> dict:
return obj
+def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]:
+ """
+ Attempt to repair a config file that failed strict parsing, WITHOUT writing
+ anything to disk. The caller decides what to do with the result (BCC shows
+ the user the issues and asks before saving).
+
+ Returns (cfg, notes, repaired_text):
+ cfg - the parsed dict from the repaired JSON
+ notes - human-readable list of fixes that were applied
+ repaired_text - pretty-printed JSON the config would become
+
+ Raises ValueError if the file can't be salvaged automatically.
+ """
+ text = Path(path).read_text(encoding="utf-8")
+ candidate, notes = repair_json_text(text)
+ try:
+ obj = json.loads(candidate)
+ except json.JSONDecodeError as e:
+ raise ValueError(
+ f"Couldn't repair the file automatically (line {e.lineno}, column {e.colno}: {e.msg})."
+ ) from e
+ if not isinstance(obj, dict):
+ raise ValueError("Even after repair, the top level isn't a JSON object.")
+ pretty = json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
+ return obj, notes, pretty
+
+
def extract_servers(cfg: dict) -> list[ServerEntry]:
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers."""
out: list[ServerEntry] = []
diff --git a/tests/test_json_repair.py b/tests/test_json_repair.py
index 7618b19..99d4a93 100644
--- a/tests/test_json_repair.py
+++ b/tests/test_json_repair.py
@@ -2,6 +2,8 @@
parse_pasted_json_verbose). Every case here is a shape people actually paste
from MCP docs, blog posts, and chat windows."""
+import json
+
import pytest
import bcc_core as c
@@ -170,6 +172,35 @@ def test_kitchen_sink():
assert len(notes) >= 4
+# --------------------------------------------------------------------------- #
+# Config-file repair (load-time)
+# --------------------------------------------------------------------------- #
+def test_repair_config_file_fixes_and_reports(tmp_path):
+ p = tmp_path / "claude_desktop_config.json"
+ p.write_text(
+ '{\n "globalShortcut": "Cmd+Space",\n'
+ " // my servers\n"
+ ' "mcpServers": {\n'
+ ' "brave": {"command": "npx", "args": ["-y"],},\n'
+ " },\n}"
+ )
+ cfg, notes, preview = c.repair_config_file(p)
+ assert cfg["mcpServers"]["brave"]["command"] == "npx"
+ assert cfg["globalShortcut"] == "Cmd+Space" # unrelated keys survive
+ assert any("comment" in n for n in notes)
+ assert any("trailing" in n for n in notes)
+ assert json.loads(preview) == cfg
+ # nothing was written to disk
+ assert "//" in p.read_text()
+
+
+def test_repair_config_file_hopeless_raises(tmp_path):
+ p = tmp_path / "claude_desktop_config.json"
+ p.write_text("total nonsense, no braces at all")
+ with pytest.raises(ValueError):
+ c.repair_config_file(p)
+
+
# --------------------------------------------------------------------------- #
# Still fails cleanly on hopeless input
# --------------------------------------------------------------------------- #