""" mcp_manager.py -- A form-based editor for Claude Desktop MCP servers. Edit the `mcpServers` block of one or more Claude installs without ever hand-writing JSON. Validates, checks that each server's command actually exists on PATH, backs up before writing, and never touches any other key in your config. Run: python mcp_manager.py """ from __future__ import annotations import sys from pathlib import Path from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, Signal from PySide6.QtGui import QAction, QColor, QGuiApplication, QPainter from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) import bcc_core as core # --- One-line rebrand: change this to recolor the whole app --------------- # ACCENT = "#f97316" # warm orange ACCENT_DIM = "#c2570b" BG = "#1b1d23" PANEL = "#23262e" PANEL_2 = "#2b2f39" TEXT = "#e7e9ee" MUTED = "#9aa0ad" BORDER = "#3a3f4b" GOOD = "#4ade80" BAD = "#f87171" WARN = "#fbbf24" STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN} STATUS_GLYPH = {"ok": "●", "missing": "●", "warn": "▲", "remote": "◆", "unknown": "○"} STYLESHEET = f""" * {{ font-family: -apple-system, "Segoe UI", "Inter", "DejaVu Sans", sans-serif; font-size: 13px; color: {TEXT}; }} QMainWindow, QDialog {{ background: {BG}; }} QLabel#h1 {{ font-size: 15px; font-weight: 600; }} QLabel#muted {{ color: {MUTED}; }} QFrame#card {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; }} QLineEdit, QPlainTextEdit, QComboBox {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; padding: 6px 8px; selection-background-color: {ACCENT}; selection-color: #1a1205; }} QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {ACCENT}; }} QComboBox::drop-down {{ border: none; width: 22px; }} QComboBox QAbstractItemView {{ background: {PANEL_2}; border: 1px solid {BORDER}; selection-background-color: {ACCENT}; outline: none; }} QPushButton {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; padding: 7px 13px; }} QPushButton:hover {{ border: 1px solid {ACCENT}; }} QPushButton:disabled {{ color: {MUTED}; background: {PANEL}; }} QPushButton#primary {{ background: {ACCENT}; border: 1px solid {ACCENT}; color: #1a1205; font-weight: 600; }} QPushButton#primary:hover {{ background: {ACCENT_DIM}; }} QPushButton#primary:disabled {{ background: {PANEL}; color: {MUTED}; border: 1px solid {BORDER}; }} QPushButton#danger:hover {{ border: 1px solid {BAD}; color: {BAD}; }} QTableWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; gridline-color: transparent; outline: none; }} QTableWidget::item {{ padding: 6px 8px; border: none; }} QTableWidget::item:selected {{ background: {ACCENT}; color: #1a1205; }} /* Inline cell editors: the global QLineEdit padding/radius clips the text inside a table row, so give editors a compact, flat style instead. */ QTableWidget QLineEdit {{ background: {PANEL_2}; color: {TEXT}; border: 1px solid {ACCENT}; border-radius: 3px; padding: 0px 4px; margin: 0px; selection-background-color: {ACCENT_DIM}; selection-color: #ffffff; }} QHeaderView::section {{ background: {PANEL}; color: {MUTED}; border: none; border-bottom: 1px solid {BORDER}; padding: 8px; font-weight: 600; }} QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }} QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }} QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }} QTableWidget#disabledTable {{ background: #202229; }} QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a1205; }} QPlainTextEdit#diag {{ font-family: "SF Mono", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace; font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }} QFrame#diagCard {{ background: transparent; border: none; }} QSplitter::handle {{ background: transparent; }} QSplitter::handle:hover {{ background: {BORDER}; border-radius: 4px; }} QSplitter::handle:pressed {{ background: {ACCENT}; border-radius: 4px; }} """ # --------------------------------------------------------------------------- # # Background reachability tester (keeps the UI responsive during the request) # --------------------------------------------------------------------------- # class ConnTester(QThread): done = Signal(bool, str) def __init__(self, url, timeout=5.0): super().__init__() self.url = url self.timeout = timeout def run(self): ok, detail = core.test_remote(self.url, self.timeout) self.done.emit(ok, detail) # --------------------------------------------------------------------------- # # Small reusable: key/value editor (for env and headers) # --------------------------------------------------------------------------- # class KeyValueTable(QWidget): def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None): super().__init__() self._on_change = on_change self._before_change = before_change self._key_label = key_label self._val_label = val_label lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(6) self.table = QTableWidget(0, 2) self.table.setHorizontalHeaderLabels([key_label, val_label]) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.table.verticalHeader().setVisible(False) # Rows tall enough that the inline editor doesn't crush the text. self.table.verticalHeader().setDefaultSectionSize(34) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setMinimumHeight(90) self.table.itemChanged.connect(self._changed) lay.addWidget(self.table, 1) row = QHBoxLayout() add = QPushButton("+ Add") rem = QPushButton("− Remove") add.clicked.connect(self._add_row) rem.clicked.connect(self._remove_row) row.addWidget(add) row.addWidget(rem) row.addStretch() lay.addLayout(row) def _changed(self, *_): if self._on_change: self._on_change() def _add_row(self): dlg = QDialog(self.window()) dlg.setWindowTitle(f"Add {self._key_label}") dlg.setMinimumWidth(340) v = QVBoxLayout(dlg) v.setSpacing(10) grid = QGridLayout() grid.setSpacing(8) lbl_k = QLabel(f"{self._key_label}:") lbl_k.setObjectName("muted") grid.addWidget(lbl_k, 0, 0) key_edit = QLineEdit() key_edit.setPlaceholderText( "e.g. API_KEY" if self._key_label == "Variable" else f"{self._key_label.lower()}-name" ) grid.addWidget(key_edit, 0, 1) lbl_v = QLabel(f"{self._val_label}:") lbl_v.setObjectName("muted") grid.addWidget(lbl_v, 1, 0) val_edit = QLineEdit() val_edit.setPlaceholderText("value") grid.addWidget(val_edit, 1, 1) v.addLayout(grid) btns = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok) ok_btn.setObjectName("primary") ok_btn.setEnabled(False) key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip()))) btns.accepted.connect(dlg.accept) btns.rejected.connect(dlg.reject) v.addWidget(btns) key_edit.setFocus() # Enter in key field moves to value; Enter in value field submits key_edit.returnPressed.connect(val_edit.setFocus) val_edit.returnPressed.connect(lambda: ok_btn.click() if ok_btn.isEnabled() else None) if dlg.exec() != QDialog.DialogCode.Accepted: return k = key_edit.text().strip() val = val_edit.text() if not k: return if self._before_change: self._before_change() r = self.table.rowCount() self.table.insertRow(r) self.table.setItem(r, 0, QTableWidgetItem(k)) self.table.setItem(r, 1, QTableWidgetItem(val)) self._changed() def _remove_row(self): r = self.table.currentRow() if r < 0: return if self._before_change: self._before_change() self.table.removeRow(r) self._changed() def load(self, d: dict): self.table.blockSignals(True) self.table.setRowCount(0) for k, v in (d or {}).items(): r = self.table.rowCount() self.table.insertRow(r) self.table.setItem(r, 0, QTableWidgetItem(str(k))) self.table.setItem(r, 1, QTableWidgetItem(str(v))) self.table.blockSignals(False) def dump(self) -> dict: out = {} for r in range(self.table.rowCount()): k = self.table.item(r, 0) v = self.table.item(r, 1) k = k.text().strip() if k else "" v = v.text() if v else "" if k: out[k] = v return out # --------------------------------------------------------------------------- # # The server editor panel (right side) # --------------------------------------------------------------------------- # class ServerEditor(QFrame): def __init__(self, on_change, before_change=None): super().__init__() self.setObjectName("card") self._on_change = on_change self._before_change = before_change # called before any row add/remove in tables self._extra = {} # preserve unknown server fields verbatim self._loading = False outer = QVBoxLayout(self) outer.setContentsMargins(16, 16, 16, 16) outer.setSpacing(12) title = QLabel("Server details") title.setObjectName("h1") outer.addWidget(title) grid = QGridLayout() grid.setVerticalSpacing(10) grid.setHorizontalSpacing(10) grid.addWidget(self._lbl("Name"), 0, 0) self.name = QLineEdit() self.name.setPlaceholderText("e.g. filesystem") self.name.textChanged.connect(self._emit) grid.addWidget(self.name, 0, 1) grid.addWidget(self._lbl("Type"), 1, 0) self.type = QComboBox() self.type.addItems(["Local command (stdio)", "Remote (url)"]) self.type.currentIndexChanged.connect(self._type_switched) grid.addWidget(self.type, 1, 1) outer.addLayout(grid) # Stacked: stdio page / remote page self.stack = QStackedWidget() self.stack.addWidget(self._build_stdio_page()) self.stack.addWidget(self._build_remote_page()) outer.addWidget(self.stack, 1) # Dependency status row dep = QHBoxLayout() self.dep_dot = QLabel("○") self.dep_label = QLabel("—") self.dep_label.setObjectName("muted") self.fix_btn = QPushButton("Use full path ↳") self.fix_btn.setToolTip( "Rewrite the bare command to its absolute path so any launch of Claude can find it" ) self.fix_btn.clicked.connect(self._pin_path) self.fix_btn.setVisible(False) self.test_btn = QPushButton("Test connection") self.test_btn.clicked.connect(self._test_remote) self.test_btn.setVisible(False) self.details_btn = QPushButton("Details ▸") self.details_btn.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) recheck = QPushButton("Re-check") recheck.clicked.connect(self._recheck) dep.addWidget(self.dep_dot) dep.addWidget(self.dep_label, 1) dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) # Collapsible diagnostics panel self.diag_card = QFrame() self.diag_card.setObjectName("diagCard") dlay = QVBoxLayout(self.diag_card) dlay.setContentsMargins(0, 0, 0, 0) dlay.setSpacing(6) self.diag_text = QPlainTextEdit() self.diag_text.setObjectName("diag") self.diag_text.setReadOnly(True) self.diag_text.setMaximumHeight(190) dlay.addWidget(self.diag_text) crow = QHBoxLayout() crow.addStretch() self.copy_diag_btn = QPushButton("Copy diagnostics") self.copy_diag_btn.clicked.connect(self._copy_diag) crow.addWidget(self.copy_diag_btn) dlay.addLayout(crow) self.diag_card.setVisible(False) outer.addWidget(self.diag_card) self.setEnabled(False) def _lbl(self, t): lbl = QLabel(t) lbl.setObjectName("muted") return lbl def _build_stdio_page(self): w = QWidget() v = QVBoxLayout(w) v.setContentsMargins(0, 0, 0, 0) v.setSpacing(8) v.addWidget(self._lbl("Command")) self.command = QLineEdit() self.command.setPlaceholderText("npx, uvx, node, python3, or a full path") self.command.textChanged.connect(self._emit) v.addWidget(self.command) v.addWidget(self._lbl("Arguments (one per line)")) self.args = ArgsEdit() self.args.setPlaceholderText( "-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents" ) self.args.setMinimumHeight(70) self.args.textChanged.connect(self._emit) v.addWidget(self.args, 1) v.addWidget(self._lbl("Environment variables")) self.env = KeyValueTable( "Variable", "Value", on_change=self._emit, before_change=self._before_change ) v.addWidget(self.env, 1) return w def _build_remote_page(self): w = QWidget() v = QVBoxLayout(w) v.setContentsMargins(0, 0, 0, 0) v.setSpacing(8) v.addWidget(self._lbl("URL")) self.url = QLineEdit() self.url.setPlaceholderText("https://mcp.example.com/sse") self.url.textChanged.connect(self._emit) v.addWidget(self.url) v.addWidget(self._lbl("Transport")) self.transport = QComboBox() self.transport.addItems(["(auto)", "http", "sse"]) self.transport.currentIndexChanged.connect(self._emit) v.addWidget(self.transport) v.addWidget(self._lbl("Headers")) self.headers = KeyValueTable( "Header", "Value", on_change=self._emit, before_change=self._before_change ) v.addWidget(self.headers, 1) return w # --- model <-> form -------------------------------------------------- # def load_entry(self, entry: core.ServerEntry | None): self._loading = True if entry is None: self.setEnabled(False) self.name.clear() self.command.clear() self.args.clear() self.url.clear() self.env.load({}) self.headers.load({}) self.details_btn.setChecked(False) self.diag_text.clear() self._set_dep({"status": "unknown", "label": "—"}) self._loading = False return self.setEnabled(True) d = entry.data self._extra = {k: v for k, v in d.items() if k not in core.KNOWN_FIELDS} self.name.setText(entry.name) # Reset BOTH pages first so a previously-selected server's values can't # leak in if the user flips the type toggle mid-edit. self.command.clear() self.args.clear() self.env.load({}) self.url.clear() self.transport.setCurrentIndex(0) self.headers.load({}) if entry.kind == "remote": self.type.setCurrentIndex(1) self.stack.setCurrentIndex(1) self.url.setText(str(d.get("url", ""))) t = d.get("type") self.transport.setCurrentText(t if t in ("http", "sse") else "(auto)") self.headers.load(d.get("headers", {})) else: self.type.setCurrentIndex(0) self.stack.setCurrentIndex(0) self.command.setText(str(d.get("command", ""))) self.args.setPlainText("\n".join(str(a) for a in (d.get("args") or []))) self.env.load(d.get("env", {})) self._loading = False self.refresh_dependency(auto_open=True) def dump_data(self) -> dict: """Build the server dict from the form, preserving unknown fields.""" data = dict(self._extra) if self.type.currentIndex() == 1: # remote data["url"] = self.url.text().strip() t = self.transport.currentText() if t in ("http", "sse"): data["type"] = t else: data.pop("type", None) headers = self.headers.dump() if headers: data["headers"] = headers else: data.pop("headers", None) for k in ("command", "args", "env"): data.pop(k, None) else: # stdio data["command"] = self.command.text().strip() args = [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""] if args: data["args"] = args else: data.pop("args", None) env = self.env.dump() if env: data["env"] = env else: data.pop("env", None) for k in ("url", "type", "headers"): data.pop(k, None) return data def current_name(self) -> str: return self.name.text().strip() def _type_switched(self): self.stack.setCurrentIndex(self.type.currentIndex()) self._emit() def _emit(self): if not self._loading and self._on_change: self._on_change() self.refresh_dependency(auto_open=False) # --- dependency ------------------------------------------------------ # def refresh_dependency(self, auto_open=False): if not self.isEnabled(): self._set_dep({"status": "unknown", "label": "—"}) self.diag_text.clear() return data = self.dump_data() res = core.check_dependency(data) self._set_dep(res) self.fix_btn.setVisible(res["status"] == "warn") self.test_btn.setVisible(self.type.currentIndex() == 1) problem = res["status"] in ("missing", "warn", "unknown") if auto_open and problem and not self.details_btn.isChecked(): self.details_btn.setChecked(True) # opens panel (fills text via _toggle_diag) if self.diag_card.isVisible(): self.diag_text.setPlainText(core.diagnostics_text(self.current_name(), data)) def _set_dep(self, res: dict): status = res.get("status", "unknown") self.dep_dot.setText(STATUS_GLYPH.get(status, "○")) self.dep_dot.setStyleSheet(f"color: {STATUS_COLORS.get(status, MUTED)}; font-size: 14px;") self.dep_label.setText(res.get("label", "—")) color = STATUS_COLORS.get(status, MUTED) if status in ("missing", "warn") else MUTED self.dep_label.setStyleSheet(f"color: {color};") def _toggle_diag(self, on): self.diag_card.setVisible(on) self.details_btn.setText("Details ▾" if on else "Details ▸") if on and self.isEnabled(): self.diag_text.setPlainText( core.diagnostics_text(self.current_name(), self.dump_data()) ) def _recheck(self): core.refresh_path_cache() # re-scan PATH, e.g. after installing a runtime self.refresh_dependency(auto_open=True) def _copy_diag(self): QGuiApplication.clipboard().setText(self.diag_text.toPlainText()) self.copy_diag_btn.setText("Copied ✓") QTimer.singleShot(1200, lambda: self.copy_diag_btn.setText("Copy diagnostics")) def _pin_path(self): new_data, note = core.pin_command_path(self.dump_data()) if not note: return self._loading = True self.command.setText(str(new_data.get("command", ""))) self.args.setPlainText("\n".join(str(a) for a in (new_data.get("args") or []))) self._loading = False self._emit() # writes back to the model and re-checks (should now be ok) def _test_remote(self): url = self.url.text().strip() if not url: return self.test_btn.setEnabled(False) self.test_btn.setText("Testing...") self.dep_dot.setText("◆") self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;") self.dep_label.setText("testing reachability...") self.dep_label.setStyleSheet(f"color: {MUTED};") self._tester = ConnTester(url, timeout=6.0) self._tester.done.connect(self._on_test_done) self._tester.start() def _on_test_done(self, ok, detail): self.test_btn.setEnabled(True) self.test_btn.setText("Test connection") color = GOOD if ok else BAD self.dep_dot.setText("●") self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;") self.dep_label.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}") self.dep_label.setStyleSheet(f"color: {color};") # --------------------------------------------------------------------------- # # Arguments editor: one line = one argument, with a numbered gutter so that # model is visible (a long path on its own line reads as "arg 2", not as a # wrapped continuation of arg 1). # --------------------------------------------------------------------------- # class _ArgsGutter(QWidget): def __init__(self, editor: ArgsEdit): super().__init__(editor) self._editor = editor def sizeHint(self): return QSize(self._editor.gutter_width(), 0) def paintEvent(self, event): self._editor.gutter_paint(event) class ArgsEdit(QPlainTextEdit): def __init__(self): super().__init__() self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self._gutter = _ArgsGutter(self) self.blockCountChanged.connect(self._update_gutter_width) self.updateRequest.connect(self._on_update_request) self._update_gutter_width() def gutter_width(self) -> int: digits = max(1, len(str(self.blockCount()))) return 14 + self.fontMetrics().horizontalAdvance("9") * digits def _update_gutter_width(self, *_): self.setViewportMargins(self.gutter_width(), 0, 0, 0) def _on_update_request(self, rect, dy): if dy: self._gutter.scroll(0, dy) else: self._gutter.update(0, rect.y(), self._gutter.width(), rect.height()) if rect.contains(self.viewport().rect()): self._update_gutter_width() def resizeEvent(self, e): super().resizeEvent(e) cr = self.contentsRect() self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.gutter_width(), cr.height())) def gutter_paint(self, event): painter = QPainter(self._gutter) painter.setPen(QColor(MUTED)) fm = self.fontMetrics() block = self.firstVisibleBlock() n = block.blockNumber() top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) bottom = top + round(self.blockBoundingRect(block).height()) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): painter.drawText( 0, top, self._gutter.width() - 8, fm.height(), Qt.AlignmentFlag.AlignRight, str(n + 1), ) block = block.next() top = bottom bottom = top + round(self.blockBoundingRect(block).height()) n += 1 # --------------------------------------------------------------------------- # # Paste-JSON dialog # --------------------------------------------------------------------------- # class PasteDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Paste MCP JSON") self.resize(560, 420) self.result_servers: dict[str, dict] | None = None v = QVBoxLayout(self) info = QLabel( "Paste a config snippet from any MCP doc — it doesn't have to be " "perfect JSON. Markdown fences, comments, trailing commas, smart " "quotes, and missing braces are fixed automatically." ) info.setObjectName("muted") info.setWordWrap(True) v.addWidget(info) self.box = QPlainTextEdit() self.box.setPlaceholderText( '{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}' ) v.addWidget(self.box, 1) self.err = QLabel("") self.err.setWordWrap(True) v.addWidget(self.err) btns = QDialogButtonBox() self.parse_btn = btns.addButton("Parse & Add", QDialogButtonBox.ButtonRole.AcceptRole) self.parse_btn.setObjectName("primary") self.parse_btn.setEnabled(False) btns.addButton(QDialogButtonBox.StandardButton.Cancel) btns.accepted.connect(self._try_parse) btns.rejected.connect(self.reject) v.addWidget(btns) # Live parse-as-you-type, debounced so we don't churn on every keystroke. self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(250) self._debounce.timeout.connect(self._preview) self.box.textChanged.connect(self._debounce.start) def _preview(self): text = self.box.toPlainText() if not text.strip(): self.err.setText("") self.parse_btn.setEnabled(False) return try: servers, notes = core.parse_pasted_json_verbose(text) except Exception as e: self.err.setStyleSheet(f"color: {BAD};") self.err.setText(str(e)) self.parse_btn.setEnabled(False) return names = ", ".join( f"{n} ({core.ServerEntry(n, d).kind})" for n, d in list(servers.items())[:6] ) if len(servers) > 6: names += f", +{len(servers) - 6} more" msg = f"✓ Will add: {names}" if notes: msg += "\nAuto-repaired: " + "; ".join(notes) self.err.setStyleSheet(f"color: {GOOD};") self.err.setText(msg) self.parse_btn.setEnabled(True) def _try_parse(self): try: self.result_servers = core.parse_pasted_json(self.box.toPlainText()) self.accept() except Exception as e: self.err.setStyleSheet(f"color: {BAD};") self.err.setText(str(e)) # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- # class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Better Claude Config") self.resize(940, 640) self.setAcceptDrops(True) self.profiles: list[core.Profile] = [] self.full_config: dict = {} self.servers: list[core.ServerEntry] = [] self.current_profile: core.Profile | None = None self.dirty = False self._suppress_table = False self._suppress_sel = False self._focused_table = None self._row_of_index: dict[int, tuple] = {} self._undo_stack: list[list] = [] # each entry: snapshot of self.servers central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(14, 14, 14, 10) root.setSpacing(12) root.addLayout(self._build_topbar()) # User-draggable divider between the server list and the editor. split = QSplitter(Qt.Orientation.Horizontal) split.setChildrenCollapsible(False) split.setHandleWidth(10) split.addWidget(self._build_left()) self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo) split.addWidget(self.editor) split.setStretchFactor(0, 3) split.setStretchFactor(1, 4) split.setSizes([390, 520]) self.hsplit = split root.addWidget(split, 1) root.addLayout(self._build_actionbar()) self.status = QLabel("Ready.") self.status.setObjectName("statusbar") root.addWidget(self.status) self._restore_layout() self.reload_profiles() # --- layout persistence ---------------------------------------------- # def _restore_layout(self): st = QSettings("BCC", "BetterClaudeConfig") if geo := st.value("window/geometry"): self.restoreGeometry(geo) if hs := st.value("window/hsplit"): self.hsplit.restoreState(hs) if vs := st.value("window/vsplit"): self.vsplit.restoreState(vs) def _save_layout(self): st = QSettings("BCC", "BetterClaudeConfig") st.setValue("window/geometry", self.saveGeometry()) st.setValue("window/hsplit", self.hsplit.saveState()) st.setValue("window/vsplit", self.vsplit.saveState()) # --- top bar --------------------------------------------------------- # def _build_topbar(self): bar = QHBoxLayout() lbl = QLabel("Install") lbl.setObjectName("muted") bar.addWidget(lbl) self.profile_combo = QComboBox() self.profile_combo.setMinimumWidth(220) self.profile_combo.currentIndexChanged.connect(self._profile_selected) bar.addWidget(self.profile_combo) add_cfg = QPushButton("Add config...") add_cfg.clicked.connect(self.add_custom_config) bar.addWidget(add_cfg) reload_btn = QPushButton("Reload") reload_btn.clicked.connect(lambda: self.load_profile(self.current_profile, confirm=True)) bar.addWidget(reload_btn) bar.addStretch() self.save_btn = QPushButton("Save") self.save_btn.setObjectName("primary") self.save_btn.clicked.connect(self.save) bar.addWidget(self.save_btn) return bar # --- left (server table) -------------------------------------------- # def _make_server_table(self, object_name=None): t = QTableWidget(0, 4) if object_name: t.setObjectName(object_name) t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status"]) t.verticalHeader().setVisible(False) t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) t.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) h = t.horizontalHeader() h.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl)) t.itemChanged.connect(self._table_item_changed) return t def _build_left(self): card = QFrame() card.setObjectName("card") v = QVBoxLayout(card) v.setContentsMargins(12, 12, 12, 12) v.setSpacing(8) head = QLabel("MCP servers") head.setObjectName("h1") v.addWidget(head) # Active and Disabled sections live in a vertical splitter so the user # can drag the divider instead of being stuck with a fixed-height # disabled list. active_box = QWidget() av = QVBoxLayout(active_box) av.setContentsMargins(0, 0, 0, 0) av.setSpacing(8) self.active_label = QLabel("Active") self.active_label.setObjectName("section") av.addWidget(self.active_label) self.active_table = self._make_server_table() av.addWidget(self.active_table, 1) self.active_empty = QLabel("No active servers. Add one, or Paste JSON.") self.active_empty.setObjectName("placeholder") self.active_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) self.active_empty.hide() av.addWidget(self.active_empty) disabled_box = QWidget() dv = QVBoxLayout(disabled_box) dv.setContentsMargins(0, 0, 0, 0) dv.setSpacing(8) self.disabled_label = QLabel("⊘ Disabled") self.disabled_label.setObjectName("sectionDisabled") dv.addWidget(self.disabled_label) self.disabled_table = self._make_server_table(object_name="disabledTable") dv.addWidget(self.disabled_table, 1) self.disabled_empty = QLabel("Nothing disabled.") self.disabled_empty.setObjectName("placeholder") self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) dv.addWidget(self.disabled_empty) vsplit = QSplitter(Qt.Orientation.Vertical) vsplit.setChildrenCollapsible(False) vsplit.setHandleWidth(10) vsplit.addWidget(active_box) vsplit.addWidget(disabled_box) vsplit.setStretchFactor(0, 3) vsplit.setStretchFactor(1, 1) vsplit.setSizes([360, 150]) self.vsplit = vsplit v.addWidget(vsplit, 1) return card # --- bottom action bar ---------------------------------------------- # def _build_actionbar(self): bar = QHBoxLayout() self.add_btn = QPushButton("+ Add") self.dup_btn = QPushButton("Duplicate") self.del_btn = QPushButton("Delete") self.del_btn.setObjectName("danger") self.paste_btn = QPushButton("Paste JSON...") self.copy_btn = QPushButton("Copy to ▸") self.undo_btn = QPushButton("Undo") self.undo_btn.setEnabled(False) self.undo_btn.setToolTip("Undo last change (Ctrl+Z)") self.add_btn.clicked.connect(self.add_server) self.dup_btn.clicked.connect(self.duplicate_server) self.del_btn.clicked.connect(self.delete_server) self.paste_btn.clicked.connect(self.paste_json) self.copy_btn.clicked.connect(self.copy_to_menu) self.undo_btn.clicked.connect(self._undo) for b in ( self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, self.copy_btn, self.undo_btn, ): bar.addWidget(b) # Ctrl+Z shortcut undo_action = QAction(self) undo_action.setShortcut("Ctrl+Z") undo_action.triggered.connect(self._undo) self.addAction(undo_action) bar.addStretch() self.validation_lbl = QLabel("") bar.addWidget(self.validation_lbl) return bar # --- undo ------------------------------------------------------------ # def _push_undo(self): """Snapshot the current server list onto the undo stack.""" self._undo_stack.append( [core.ServerEntry(s.name, dict(s.data), s.enabled) for s in self.servers] ) if len(self._undo_stack) > 50: self._undo_stack.pop(0) self.undo_btn.setEnabled(True) def _undo(self): if not self._undo_stack: return self.servers = self._undo_stack.pop() self.undo_btn.setEnabled(bool(self._undo_stack)) cur = self._current_index() sel = cur if 0 <= cur < len(self.servers) else (0 if self.servers else -1) self._refresh_tables(select_index=sel) self._mark_dirty() self.status.setText("Undone.") # --- profiles -------------------------------------------------------- # def reload_profiles(self): discovered = core.discover_profiles() # keep any manually-added custom profiles around customs = [p for p in self.profiles if str(p.path) not in {str(d.path) for d in discovered}] self.profiles = discovered + customs self.profile_combo.blockSignals(True) self.profile_combo.clear() for p in self.profiles: tag = "" if p.config_exists else " (no config yet)" self.profile_combo.addItem(f"{p.label}{tag}") self.profile_combo.blockSignals(False) if self.profiles: self.profile_combo.setCurrentIndex(0) self.load_profile(self.profiles[0]) else: self.status.setText('No Claude installs found. Use "Add config…" to point at one.') def add_custom_config(self): start = str(core.app_support_base()) path, _ = QFileDialog.getOpenFileName( self, "Select a claude_desktop_config.json", start, "JSON (*.json);;All files (*)" ) if not path: return prof = core.profile_from_path(path) if str(prof.path) in {str(p.path) for p in self.profiles}: idx = [str(p.path) for p in self.profiles].index(str(prof.path)) self.profile_combo.setCurrentIndex(idx) return self.profiles.append(prof) self.profile_combo.addItem(f"{prof.label} (custom)") self.profile_combo.setCurrentIndex(self.profile_combo.count() - 1) def _profile_selected(self, idx): if 0 <= idx < len(self.profiles): self.load_profile(self.profiles[idx]) def load_profile(self, profile: core.Profile | None, confirm=False): if profile is None: return if confirm and self.dirty and not self._confirm_discard(): return 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 self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False self._undo_stack.clear() self.undo_btn.setEnabled(False) self._refresh_tables(select_index=0 if self.servers else -1) self._update_status(saved=False) # --- table rendering ------------------------------------------------- # def _add_row(self, table, master_idx, s): r = table.rowCount() table.insertRow(r) on = QTableWidgetItem() on.setFlags( Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable ) on.setCheckState(Qt.CheckState.Checked if s.enabled else Qt.CheckState.Unchecked) table.setItem(r, 0, on) name_item = QTableWidgetItem(s.name) name_item.setData(Qt.ItemDataRole.UserRole, master_idx) # row -> master list index if not s.enabled: name_item.setForeground(QColor(MUTED)) table.setItem(r, 1, name_item) type_item = QTableWidgetItem("remote" if s.kind == "remote" else "local") if not s.enabled: type_item.setForeground(QColor(MUTED)) table.setItem(r, 2, type_item) dep = core.check_dependency(s.data) st = QTableWidgetItem(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}") st.setForeground( QColor(STATUS_COLORS.get(dep["status"], MUTED)) ) # status stays colored even when off table.setItem(r, 3, st) self._row_of_index[master_idx] = (table, r) def _refresh_tables(self, select_index=None): self._suppress_table = True self._suppress_sel = True self._row_of_index = {} self.active_table.setRowCount(0) self.disabled_table.setRowCount(0) n_active = n_disabled = 0 for i, s in enumerate(self.servers): if s.enabled: self._add_row(self.active_table, i, s) n_active += 1 else: self._add_row(self.disabled_table, i, s) n_disabled += 1 self.active_table.setVisible(n_active > 0) self.active_empty.setVisible(n_active == 0) self.disabled_table.setVisible(n_disabled > 0) self.disabled_empty.setVisible(n_disabled == 0) self._refresh_badges() self._suppress_table = False self._suppress_sel = False if select_index is not None and select_index in self._row_of_index: table, row = self._row_of_index[select_index] self._select_in(table, row) else: self._load_editor_from_selection() self._validate() def _section_html(self, title, n, miss, warn): base = f"{title} · {n}" if miss: return f'{base}   ▲ {miss} broken' if warn: return f'{base}   ▲ {warn} PATH-risk' return base def _refresh_badges(self): n_a = n_d = a_miss = a_warn = d_miss = d_warn = 0 a_names, d_names = [], [] for s in self.servers: st = core.check_dependency(s.data)["status"] if s.enabled: n_a += 1 if st == "missing": a_miss += 1 a_names.append(s.name) elif st == "warn": a_warn += 1 a_names.append(s.name) else: n_d += 1 if st == "missing": d_miss += 1 d_names.append(s.name) elif st == "warn": d_warn += 1 d_names.append(s.name) self.active_label.setText(self._section_html("Active", n_a, a_miss, a_warn)) self.active_label.setToolTip(("Needs attention: " + ", ".join(a_names)) if a_names else "") self.disabled_label.setText(self._section_html("⊘ Disabled", n_d, d_miss, d_warn)) self.disabled_label.setToolTip( ("Needs attention: " + ", ".join(d_names)) if d_names else "" ) def _select_in(self, table, row): self._suppress_sel = True other = self.disabled_table if table is self.active_table else self.active_table other.clearSelection() other.setCurrentCell(-1, -1) self._suppress_sel = False self._focused_table = table table.selectRow(row) # fires _on_selection -> loads editor # --- selection across the two tables -------------------------------- # def _on_selection(self, table): if self._suppress_sel: return if table.currentRow() < 0: return self._suppress_sel = True other = self.disabled_table if table is self.active_table else self.active_table other.clearSelection() other.setCurrentCell(-1, -1) self._suppress_sel = False self._focused_table = table self._load_editor_from_selection() def _current_index(self) -> int: t = self._focused_table if t is None: return -1 r = t.currentRow() if r < 0: return -1 item = t.item(r, 1) if item is None: return -1 idx = item.data(Qt.ItemDataRole.UserRole) return idx if idx is not None else -1 def _load_editor_from_selection(self): idx = self._current_index() entry = self.servers[idx] if 0 <= idx < len(self.servers) else None self.editor.load_entry(entry) self.copy_btn.setEnabled(entry is not None and len(self.profiles) > 1) self.dup_btn.setEnabled(entry is not None) self.del_btn.setEnabled(entry is not None) def _table_item_changed(self, item: QTableWidgetItem): if self._suppress_table or item.column() != 0: return table = item.tableWidget() name_item = table.item(item.row(), 1) if name_item is None: return idx = name_item.data(Qt.ItemDataRole.UserRole) if idx is None or not (0 <= idx < len(self.servers)): return self._push_undo() self.servers[idx].enabled = item.checkState() == Qt.CheckState.Checked # Toggling moves the server to the other section; keep it selected there. self._refresh_tables(select_index=idx) self._mark_dirty() # --- editor change --------------------------------------------------- # def _editor_changed(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return entry = self.servers[idx] entry.name = self.editor.current_name() entry.data = self.editor.dump_data() # The server stays in its section (enable state unchanged), so update # its existing row in place rather than re-rendering. loc = self._row_of_index.get(idx) if loc: table, row = loc self._suppress_table = True table.item(row, 1).setText(entry.name) table.item(row, 2).setText("remote" if entry.kind == "remote" else "local") dep = core.check_dependency(entry.data) st = table.item(row, 3) st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}") st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED))) self._suppress_table = False self._refresh_badges() self._mark_dirty() # --- server actions -------------------------------------------------- # def add_server(self): self._push_undo() base = "new-server" name = base n = 2 existing = {s.name for s in self.servers} while name in existing: name = f"{base}-{n}" n += 1 self.servers.append(core.ServerEntry(name, {"command": "npx", "args": []}, True)) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.editor.name.setFocus() self.editor.name.selectAll() def duplicate_server(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return self._push_undo() src = self.servers[idx] existing = {s.name for s in self.servers} name = f"{src.name}-copy" n = 2 while name in existing: name = f"{src.name}-copy-{n}" n += 1 self.servers.insert(idx + 1, core.ServerEntry(name, dict(src.data), src.enabled)) self._refresh_tables(select_index=idx + 1) self._mark_dirty() def delete_server(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return name = self.servers[idx].name if ( QMessageBox.question(self, "Delete server", f"Remove “{name}”?") != QMessageBox.StandardButton.Yes ): return self._push_undo() del self.servers[idx] self._refresh_tables(select_index=min(idx, len(self.servers) - 1)) self._mark_dirty() def paste_json(self): dlg = PasteDialog(self) if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers: return self._push_undo() added, replaced = 0, 0 existing = {s.name: i for i, s in enumerate(self.servers)} for name, data in dlg.result_servers.items(): if name in existing: ans = QMessageBox.question( self, "Server exists", f"“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if ans == QMessageBox.StandardButton.Yes: self.servers[existing[name]].data = data replaced += 1 continue new = f"{name}-2" k = 3 names = {s.name for s in self.servers} while new in names: new = f"{name}-{k}" k += 1 name = new self.servers.append(core.ServerEntry(name, data, True)) added += 1 self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.") def copy_to_menu(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return menu = QMenu(self) for p in self.profiles: if self.current_profile and str(p.path) == str(self.current_profile.path): continue act = QAction(p.label, self) act.triggered.connect(lambda _=False, prof=p: self._copy_to(prof, idx)) menu.addAction(act) if menu.isEmpty(): return self.copy_btn.setMenu(menu) self.copy_btn.showMenu() def _copy_to(self, dest: core.Profile, idx: int): src = self.servers[idx] try: dest_cfg = core.load_config(dest.path) except Exception as e: QMessageBox.critical(self, "Copy failed", f"Couldn't read {dest.label}:\n{e}") return existing = core.extract_servers(dest_cfg) names = {s.name for s in existing} if src.name in names: ans = QMessageBox.question( self, "Exists in destination", f"“{src.name}” already exists in {dest.label}. Overwrite it there?", ) if ans != QMessageBox.StandardButton.Yes: return existing = [s for s in existing if s.name != src.name] existing.append(core.ServerEntry(src.name, dict(src.data), True)) core.apply_servers(dest_cfg, existing) try: backup = core.write_config(dest.path, dest_cfg) except Exception as e: QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}") return bnote = f" (backup: {backup.name})" if backup else "" self.status.setText( f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply." ) # --- save ------------------------------------------------------------ # def _validate(self) -> bool: problems = core.validate_servers(self.servers) if problems: self.validation_lbl.setText(f"⚠ {problems[0]}") self.validation_lbl.setStyleSheet(f"color: {WARN};") self.save_btn.setEnabled(False) return False self.validation_lbl.setText("✓ valid") self.validation_lbl.setStyleSheet(f"color: {GOOD};") self.save_btn.setEnabled(self.dirty) return True def save(self): if not self.current_profile: return if not self._validate(): QMessageBox.warning(self, "Can't save yet", "Fix the highlighted problem first.") return core.apply_servers(self.full_config, self.servers) try: backup = core.write_config(self.current_profile.path, self.full_config) except Exception as e: QMessageBox.critical(self, "Save failed", str(e)) return self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) bnote = f" · backup: {backup.name}" if backup else " · (new file)" self.status.setText( f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply." ) # --- dirty / status -------------------------------------------------- # def _mark_dirty(self): self.dirty = True self._validate() self._update_status(saved=False) def _update_status(self, saved): if self.current_profile: n = len(self.servers) on = sum(1 for s in self.servers if s.enabled) d = " · unsaved changes" if self.dirty else "" self.status.setText(f"{self.current_profile.path} · {on}/{n} enabled{d}") def _confirm_discard(self) -> bool: return ( QMessageBox.question( self, "Discard changes?", "You have unsaved changes. Discard them?" ) == QMessageBox.StandardButton.Yes ) # --- drag & drop a .json file to import ------------------------------ # def dragEnterEvent(self, e): if e.mimeData().hasUrls() and any( u.toLocalFile().endswith(".json") for u in e.mimeData().urls() ): e.acceptProposedAction() def dropEvent(self, e): for u in e.mimeData().urls(): path = u.toLocalFile() if not path.endswith(".json"): continue text = Path(path).read_text(encoding="utf-8") try: servers = core.parse_pasted_json(text) except Exception as ex: QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}") continue self._push_undo() for name, data in servers.items(): names = {s.name for s in self.servers} if name in names: name = f"{name}-imported" self.servers.append(core.ServerEntry(name, data, True)) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.status.setText( f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save." ) break def closeEvent(self, e): if self.dirty and not self._confirm_discard(): e.ignore() return self._save_layout() e.accept() def main(): app = QApplication(sys.argv) app.setApplicationName("Better Claude Config") app.setApplicationDisplayName("Better Claude Config") app.setStyleSheet(STYLESHEET) win = MainWindow() win.show() sys.exit(app.exec()) if __name__ == "__main__": main()