From 780d4c3a9c4360ac7abb287e3dfb9d3d675953ec Mon Sep 17 00:00:00 2001 From: AJ Avezzano Date: Mon, 29 Jun 2026 14:12:50 -0400 Subject: [PATCH] Initial commit: Better Claude Config --- .gitignore | 20 + LICENSE | 21 + README.md | 76 ++++ bcc.py | 1083 ++++++++++++++++++++++++++++++++++++++++++++++ bcc_core.py | 540 +++++++++++++++++++++++ requirements.txt | 1 + test_core.py | 91 ++++ 7 files changed, 1832 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bcc.py create mode 100644 bcc_core.py create mode 100644 requirements.txt create mode 100644 test_core.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f8832b --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# PyInstaller / build +build/ +dist/ +*.spec + +# App runtime +.bcc_backups/ + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..87d523f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AJ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e607292 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Better Claude Config (BCC) + +A small, cross-platform GUI for editing the `mcpServers` block of one or more +Claude Desktop installs — without ever hand-writing JSON. + +BCC only ever touches `mcpServers` (and its own `_disabledMcpServers` parking +key). Every other key in your config is preserved verbatim, in its original +order. Each write is atomic and makes a timestamped backup first. + +## Install & run + +```bash +pip install -r requirements.txt +python bcc.py +``` + +(Python 3.10+.) + +## What it does + +- **Auto-discovers installs** — scans the platform's app-support folder for any + `Claude*` directory (so `Claude` and `Claude-Work` both show up), and lets you + **Add config…** to point at any file manually. +- **Form-based editing** — name, command, args (one per line), env vars, or for + remote servers: URL, transport, and headers. No raw JSON. +- **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block, + inner map, or a single bare server object); it's parsed and merged. +- **Drag & drop** a `.json` file onto the window to import servers from it. +- **Copy to ▸** — copy the selected server straight into your *other* install. +- **Active / Disabled sections** — servers are shown in two labelled lists with + live counts, so a server that's switched off in the config is obvious at a + glance. Toggle a server's checkbox to move it between sections (disabled + servers are parked under `_disabledMcpServers`, which Claude ignores). +- **Dependency check + diagnostics** — each server shows whether its `command` + is actually found on PATH: + - ● green — found on the normal PATH; will work anywhere. + - ▲ amber (**PATH-risk**) — found, but *only* in a location BCC added on top of + the inherited PATH. It'll work when you launch Claude from a terminal, but a + double-clicked `.app` may not see it. This is the usual cause of "the tool + says it's fine but Claude won't start the server." One click on **Use full + path ↳** rewrites the bare command (`npx`) to its absolute path so any launch + finds it. + - ● red (**broken**) — not found anywhere. + - ◆ blue — remote (url) server. **Test connection** does a live reachability + check on a background thread (any HTTP response = reachable). + + Each section header shows a **broken / PATH-risk count badge**, and selecting a + flagged server auto-opens a **Details** panel with a full, copy-pasteable + report: which command resolved to what, tailored install hints, and the exact + PATH that was searched (with `+` marking the dirs BCC added). **Re-check** + re-scans PATH after you install something; **Copy diagnostics** grabs the whole + report for a bug report. + +After saving, **restart that Claude install** for changes to take effect. + +## Config locations it scans + +| OS | Base it scans for `Claude*` | +|---------|---------------------------------| +| macOS | `~/Library/Application Support` | +| Windows | `%APPDATA%` | +| Linux | `~/.config` | + +## Files + +- `bcc.py` — the GUI. +- `bcc_core.py` — all the file/JSON/validation/dependency logic (no GUI deps). +- `test_core.py` — unit suite for the core (`python test_core.py`). + +## Rebrand + +The accent color is one constant (`ACCENT`) at the top of `bcc.py`. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/bcc.py b/bcc.py new file mode 100644 index 0000000..01fba09 --- /dev/null +++ b/bcc.py @@ -0,0 +1,1083 @@ +""" +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 Qt, QSize, QTimer, Signal, QThread +from PySide6.QtGui import QAction, QColor, QFont, QGuiApplication +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, + QLabel, QPushButton, QComboBox, QLineEdit, QPlainTextEdit, QTableWidget, + QTableWidgetItem, QHeaderView, QStackedWidget, QFileDialog, QMessageBox, + QDialog, QDialogButtonBox, QAbstractItemView, QInputDialog, QFrame, + QSizePolicy, QMenu, QStyle, +) + +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}; +}} +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; }} +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; }} +""" + + +# --------------------------------------------------------------------------- # +# 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): + super().__init__() + self._on_change = on_change + 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) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setMaximumHeight(150) + self.table.itemChanged.connect(self._changed) + lay.addWidget(self.table) + 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): + r = self.table.rowCount() + self.table.insertRow(r) + self.table.setItem(r, 0, QTableWidgetItem("")) + self.table.setItem(r, 1, QTableWidgetItem("")) + self._changed() + + def _remove_row(self): + r = self.table.currentRow() + if r >= 0: + 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): + super().__init__() + self.setObjectName("card") + self._on_change = on_change + 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) + + # 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) + outer.addStretch() + + self.setEnabled(False) + + def _lbl(self, t): + l = QLabel(t) + l.setObjectName("muted") + return l + + 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 = QPlainTextEdit() + self.args.setPlaceholderText("-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents") + self.args.setMaximumHeight(120) + self.args.textChanged.connect(self._emit) + v.addWidget(self.args) + v.addWidget(self._lbl("Environment variables")) + self.env = KeyValueTable("Variable", "Value", on_change=self._emit) + v.addWidget(self.env) + 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) + v.addWidget(self.headers) + 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};") + + +# --------------------------------------------------------------------------- # +# Paste-JSON dialog +# --------------------------------------------------------------------------- # +class PasteDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Paste MCP JSON") + self.resize(560, 380) + self.result_servers: dict[str, dict] | None = None + v = QVBoxLayout(self) + info = QLabel( + "Paste a config snippet from any MCP doc. Accepts a full " + "{\"mcpServers\": {…}} block, just the inner {\"name\": {…}} map, " + "or a single bare server object." + ) + 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.setStyleSheet(f"color: {BAD};") + 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") + btns.addButton(QDialogButtonBox.StandardButton.Cancel) + btns.accepted.connect(self._try_parse) + btns.rejected.connect(self.reject) + v.addWidget(btns) + + def _try_parse(self): + try: + self.result_servers = core.parse_pasted_json(self.box.toPlainText()) + self.accept() + except Exception as e: + 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] = {} + + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(14, 14, 14, 10) + root.setSpacing(12) + + root.addLayout(self._build_topbar()) + + split = QHBoxLayout() + split.setSpacing(12) + split.addWidget(self._build_left(), 3) + self.editor = ServerEditor(on_change=self._editor_changed) + split.addWidget(self.editor, 4) + root.addLayout(split, 1) + + root.addLayout(self._build_actionbar()) + + self.status = QLabel("Ready.") + self.status.setObjectName("statusbar") + root.addWidget(self.status) + + self.reload_profiles() + + # --- 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) + + self.active_label = QLabel("Active") + self.active_label.setObjectName("section") + v.addWidget(self.active_label) + self.active_table = self._make_server_table() + v.addWidget(self.active_table, 3) + 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() + v.addWidget(self.active_empty) + + self.disabled_label = QLabel("⊘ Disabled") + self.disabled_label.setObjectName("sectionDisabled") + v.addWidget(self.disabled_label) + self.disabled_table = self._make_server_table(object_name="disabledTable") + self.disabled_table.setMaximumHeight(170) + v.addWidget(self.disabled_table, 1) + self.disabled_empty = QLabel("Nothing disabled.") + self.disabled_empty.setObjectName("placeholder") + self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + v.addWidget(self.disabled_empty) + 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.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) + for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, self.copy_btn): + bar.addWidget(b) + bar.addStretch() + self.validation_lbl = QLabel("") + bar.addWidget(self.validation_lbl) + return bar + + # --- profiles -------------------------------------------------------- # + def reload_profiles(self): + discovered = core.discover_profiles() + existing_paths = {str(p.path) for p in self.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._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.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): + 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 + 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 + 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 + 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 + 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 + 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() diff --git a/bcc_core.py b/bcc_core.py new file mode 100644 index 0000000..0413854 --- /dev/null +++ b/bcc_core.py @@ -0,0 +1,540 @@ +""" +mcp_core.py -- Pure logic for the Claude MCP config manager. + +No GUI imports live in here on purpose: every function below is unit-testable +and could just as easily back a CLI. The GUI (mcp_manager.py) is a thin shell +over these functions. + +The cardinal rule of this module: when writing a config back to disk we ONLY +ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking +key). Every other top-level key in the user's config is preserved verbatim and +in its original position. +""" +from __future__ import annotations + +import functools +import glob +import json +import os +import shutil +import sys +import tempfile +import time +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlparse + +CONFIG_FILENAME = "claude_desktop_config.json" + +# Disabled servers are parked under this non-standard key. Claude Desktop only +# reads `mcpServers`, so anything here is ignored by the app but kept on disk so +# we can toggle it back on without losing the definition. +DISABLED_KEY = "_disabledMcpServers" + +BACKUP_DIRNAME = ".bcc_backups" +MAX_BACKUPS = 15 + +# Fields the editor knows how to render. Anything else on a server object is +# considered "extra" and is preserved untouched on save. +KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} + + +# --------------------------------------------------------------------------- # +# Data model +# --------------------------------------------------------------------------- # +@dataclass +class Profile: + """A discovered (or manually added) Claude install location.""" + label: str + path: Path + config_exists: bool + + def __post_init__(self): + self.path = Path(self.path) + + +@dataclass +class ServerEntry: + name: str + data: dict + enabled: bool = True + + @property + def kind(self) -> str: + return "remote" if "url" in self.data and "command" not in self.data else "stdio" + + +# --------------------------------------------------------------------------- # +# Discovery +# --------------------------------------------------------------------------- # +def app_support_base() -> Path: + """The per-platform directory that holds the `Claude*` data folders.""" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + +def discover_profiles() -> list[Profile]: + """ + Find every `Claude*` data directory in the platform's app-support base. + + This catches the standard `Claude` folder plus any sibling like + `Claude-Work`, `Claude-Personal`, etc. A directory is listed even if the + config file doesn't exist yet (it will be created on first save). + """ + base = app_support_base() + out: list[Profile] = [] + if base.is_dir(): + seen = set() + for d in sorted(base.glob("Claude*")): + if d.is_dir() and d.name not in seen: + seen.add(d.name) + cfg = d / CONFIG_FILENAME + out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file())) + return out + + +def profile_from_path(path: str | os.PathLike) -> Profile: + """Build a Profile from a user-supplied config path (the 'override' case).""" + p = Path(path) + # Prefer the parent folder name as the label (e.g. .../Claude-Work/...json -> Claude-Work) + label = p.parent.name or p.name + return Profile(label=label, path=p, config_exists=p.is_file()) + + +# --------------------------------------------------------------------------- # +# Load / extract / apply +# --------------------------------------------------------------------------- # +def load_config(path: str | os.PathLike) -> dict: + """Read the full config dict. Missing/empty file -> {}. Bad JSON -> raises.""" + p = Path(path) + if not p.is_file(): + return {} + text = p.read_text(encoding="utf-8") + if not text.strip(): + return {} + obj = json.loads(text) # JSONDecodeError bubbles up for the GUI to display + if not isinstance(obj, dict): + raise ValueError("Top-level JSON in the config file is not an object.") + return obj + + +def extract_servers(cfg: dict) -> list[ServerEntry]: + """Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.""" + out: list[ServerEntry] = [] + for name, data in (cfg.get("mcpServers") or {}).items(): + out.append(ServerEntry(name=name, data=dict(data), enabled=True)) + for name, data in (cfg.get(DISABLED_KEY) or {}).items(): + out.append(ServerEntry(name=name, data=dict(data), enabled=False)) + return out + + +def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict: + """ + Write the server list back into `cfg` in place, preserving every other key + and the position of `mcpServers`. Returns the same dict for convenience. + """ + enabled = {s.name: s.data for s in servers if s.enabled} + disabled = {s.name: s.data for s in servers if not s.enabled} + + cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise + if disabled: + cfg[DISABLED_KEY] = disabled + else: + cfg.pop(DISABLED_KEY, None) + return cfg + + +# --------------------------------------------------------------------------- # +# Write (atomic, with rotating backups) +# --------------------------------------------------------------------------- # +def _make_backup(path: Path) -> Path: + bdir = path.parent / BACKUP_DIRNAME + bdir.mkdir(exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + dest = bdir / f"{path.stem}.{stamp}.json" + # Avoid clobbering a same-second backup + n = 1 + while dest.exists(): + dest = bdir / f"{path.stem}.{stamp}.{n}.json" + n += 1 + shutil.copy2(path, dest) + # Prune oldest beyond MAX_BACKUPS + backups = sorted(bdir.glob(f"{path.stem}.*.json")) + for old in backups[:-MAX_BACKUPS]: + try: + old.unlink() + except OSError: + pass + return dest + + +def write_config(path: str | os.PathLike, cfg: dict) -> Path | None: + """ + Atomically write `cfg` to `path` (2-space pretty JSON). Backs up any existing + file first. Returns the backup path (or None if there was nothing to back up). + """ + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + + backup = _make_backup(p) if p.is_file() else None + + payload = json.dumps(cfg, indent=2, ensure_ascii=False) + "\n" + fd, tmp = tempfile.mkstemp(dir=str(p.parent), prefix=".tmp_mcp_", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(payload) + os.replace(tmp, p) # atomic on the same filesystem + finally: + if os.path.exists(tmp): + os.remove(tmp) + return backup + + +# --------------------------------------------------------------------------- # +# Paste / import parsing +# --------------------------------------------------------------------------- # +def looks_like_server(data) -> bool: + return isinstance(data, dict) and ("command" in data or "url" in data) + + +def suggest_name(data: dict) -> str: + """Derive a friendly default name from a server definition.""" + if "url" in data and "command" not in data: + host = (urlparse(data["url"]).hostname or "remote") + return host.split(".")[0] or "remote" + for a in data.get("args", []) or []: + if isinstance(a, str) and ("/" in a or a.startswith("@")): + base = a.rstrip("/").split("/")[-1] + base = base.replace("server-", "").replace("mcp-server-", "").replace("mcp-", "") + if base: + return base + cmd = data.get("command", "server") + return Path(str(cmd)).stem or "server" + + +def parse_pasted_json(text: str) -> dict[str, dict]: + """ + Accept any of the shapes MCP docs hand out and return {name: server_dict}: + + 1. Full config: {"mcpServers": {"name": {...}}} + 2. Inner block only: {"name": {"command": ...}} + 3. A bare server obj: {"command": ..., "args": [...]} (name is suggested) + + Raises ValueError with a human message on anything unrecognizable. + """ + text = (text or "").strip() + if not text: + raise ValueError("Nothing to parse.") + obj = json.loads(text) + if not isinstance(obj, dict): + raise ValueError("Top-level JSON must be an object ({ ... }).") + + if isinstance(obj.get("mcpServers"), dict): + servers = obj["mcpServers"] + elif looks_like_server(obj): + servers = {suggest_name(obj): obj} + elif obj and all(isinstance(v, dict) for v in obj.values()): + servers = obj + else: + raise ValueError("Couldn't find any MCP server definitions in that JSON.") + + clean: dict[str, dict] = {} + for name, data in servers.items(): + if not looks_like_server(data): + raise ValueError( + f"'{name}' doesn't look like an MCP server " + f"(it needs a 'command' or a 'url')." + ) + clean[str(name)] = data + return clean + + +# --------------------------------------------------------------------------- # +# Validation +# --------------------------------------------------------------------------- # +def validate_servers(servers: list[ServerEntry]) -> list[str]: + """Return a list of human-readable problems. Empty list == all good.""" + problems: list[str] = [] + seen: dict[str, int] = {} + for s in servers: + nm = s.name.strip() + if not nm: + problems.append("A server has an empty name.") + seen[nm] = seen.get(nm, 0) + 1 + if s.kind == "stdio": + if not str(s.data.get("command", "")).strip(): + problems.append(f"'{nm or '(unnamed)'}' is a local server but has no command.") + else: + url = str(s.data.get("url", "")).strip() + if not url: + problems.append(f"'{nm or '(unnamed)'}' is a remote server but has no URL.") + elif not (url.startswith("http://") or url.startswith("https://")): + problems.append(f"'{nm}' has a URL that isn't http(s).") + for nm, count in seen.items(): + if nm and count > 1: + problems.append(f"Duplicate server name: '{nm}' ({count}x).") + return problems + + +# --------------------------------------------------------------------------- # +# Dependency / PATH checking +# --------------------------------------------------------------------------- # +@functools.lru_cache(maxsize=1) +def augmented_path() -> str: + """ + PATH + the usual runtime install locations. GUI apps (especially bundled + .app launches) often don't inherit the shell PATH, so npx/uvx can appear + 'missing' when they're actually fine. This widens the net. + + Memoized for the session (PATH rarely changes mid-run). Call + refresh_path_cache() after the user installs something to re-scan. + """ + parts = os.environ.get("PATH", "").split(os.pathsep) + home = Path.home() + extra = [ + "/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", + str(home / ".local" / "bin"), + str(home / ".cargo" / "bin"), + str(home / ".deno" / "bin"), + str(home / ".bun" / "bin"), + str(home / ".volta" / "bin"), + ] + extra += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin")) + extra += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin")) + if os.name == "nt": + appdata = os.environ.get("APPDATA", "") + if appdata: + extra.append(str(Path(appdata) / "npm")) + + seen, ordered = set(), [] + for p in parts + extra: + if p and p not in seen and Path(p).is_dir(): + seen.add(p) + ordered.append(p) + return os.pathsep.join(ordered) + + +def refresh_path_cache(): + """Forget the memoized PATH so the next check re-scans (e.g. after an install).""" + augmented_path.cache_clear() + + +# Tailored install advice keyed by the runner's basename. +RUNNER_HINTS = { + "npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. " + "With nvm, the binary lives at ~/.nvm/versions/node//bin.", + "node": "Node.js not found. Install from nodejs.org or `brew install node`.", + "uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " + "(or `brew install uv`). uvx ships inside uv.", + "uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " + "(or `brew install uv`).", + "python": "Python not found on this PATH.", + "python3": "Python 3 not found on this PATH.", + "docker": "Docker not found. Install Docker Desktop and make sure it's running.", + "bun": "Bun not found. Install with `curl -fsSL https://bun.sh/install | bash`.", + "deno": "Deno not found. Install with `curl -fsSL https://deno.land/install.sh | sh`.", +} + + +def runner_hint(cmd: str) -> str: + base = Path(cmd).name.lower() + if base.endswith(".exe"): + base = base[:-4] + if base in RUNNER_HINTS: + return RUNNER_HINTS[base] + if ("/" in cmd) or ("\\" in cmd): + return (f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. " + f"Check the path and that it's marked executable (chmod +x).") + return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'." + + +SHELL_WRAPPERS = {"cmd", "cmd.exe", "sh", "bash", "zsh", "powershell", "powershell.exe", "pwsh"} + + +def _commands_to_check(data: dict) -> list[str]: + cmd = data.get("command") + if not cmd: + return [] + cmds = [str(cmd)] + base = Path(str(cmd)).name.lower() + if base in SHELL_WRAPPERS: + for a in data.get("args", []) or []: + if isinstance(a, str) and not a.startswith(("/", "-")) and " " not in a: + cmds.append(a) + break + return cmds + + +def check_dependency(data: dict, path: str | None = None) -> dict: + """ + Decide whether a server's command can actually be found, and gather enough + context to troubleshoot when it can't. + + status is one of: + 'ok' - resolved on the normal (inherited) PATH; will work anywhere. + 'warn' - resolved, but ONLY via an augmented location. Works in a + terminal launch; a bundled .app launch of Claude may not see it. + 'missing' - not found anywhere. + 'remote' - a url-based server (no local command to check). + 'unknown' - no command set. + + Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail} + """ + if "url" in data and "command" not in data: + return {"status": "remote", "label": "remote endpoint", "detail": data.get("url", ""), + "resolved": {}, "in_base": {}, "searched_path": "", "hints": [], "commands": []} + + cmds = _commands_to_check(data) + if not cmds: + return {"status": "unknown", "label": "no command set", "detail": "", + "resolved": {}, "in_base": {}, "searched_path": "", + "hints": ["This server has no 'command' to run."], "commands": []} + + aug = path or augmented_path() + resolved: dict[str, str | None] = {} + in_base: dict[str, bool] = {} + for c in cmds: + if (os.sep in c) or ("/" in c): # explicit path, independent of PATH + cp = Path(c) + ok = cp.exists() and os.access(cp, os.X_OK) + resolved[c] = str(cp) if ok else None + in_base[c] = ok + else: + resolved[c] = shutil.which(c, path=aug) + in_base[c] = bool(shutil.which(c)) # raw inherited PATH only + + missing = [c for c, r in resolved.items() if not r] + hints: list[str] = [] + + if missing: + for c in missing: + hints.append(runner_hint(c)) + status = "missing" + label = "missing: " + ", ".join(missing) + else: + nonbase = [c for c in resolved if not in_base[c]] + if nonbase: + status = "warn" + label = "found, but only on a non-standard PATH" + for c in nonbase: + hints.append( + f"'{c}' was found at {resolved[c]}, but only via a location this app added on " + f"top of the inherited PATH. If you launch Claude Desktop as an app (not from a " + f"terminal), it may not see this. Putting the full path in 'command' is the reliable fix." + ) + else: + status = "ok" + label = "found: " + ", ".join(Path(p).name for p in resolved.values()) + + return {"status": status, "label": label, "resolved": resolved, "in_base": in_base, + "searched_path": aug, "hints": hints, "commands": cmds, "detail": ""} + + +def diagnostics_text(name: str, data: dict) -> str: + """A copy-pasteable plain-text troubleshooting report for one server.""" + res = check_dependency(data) + L: list[str] = [] + if res["status"] == "remote": + L.append(f"Server: {name or '(unnamed)'} [remote]") + L.append(f"URL: {data.get('url', '')}") + if data.get("type"): + L.append(f"Transport: {data['type']}") + if data.get("headers"): + L.append("Headers: " + ", ".join(data["headers"].keys())) + L.append("Status: REMOTE (network reachability is not checked)") + return "\n".join(L) + + L.append(f"Server: {name or '(unnamed)'} [local / stdio]") + L.append(f"Command: {data.get('command', '')}") + if data.get("args"): + L.append("Args: " + " ".join(str(a) for a in data["args"])) + if data.get("env"): + L.append("Env keys: " + ", ".join(data["env"].keys())) + L.append(f"Status: {res['status'].upper()}") + L.append("") + L.append("Command resolution:") + for c, r in res["resolved"].items(): + if r: + tag = "" if res["in_base"].get(c) else " <-- non-standard PATH; app may not see it" + L.append(f" {c} -> {r}{tag}") + else: + L.append(f" {c} -> NOT FOUND") + if res["hints"]: + L.append("") + L.append("Hints:") + for h in res["hints"]: + L.append(f" - {h}") + + base_dirs = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p] + aug_dirs = res["searched_path"].split(os.pathsep) if res["searched_path"] else [] + extra = [d for d in aug_dirs if d not in base_dirs] + L.append("") + L.append(f"PATH searched ({len(aug_dirs)} dirs):") + for d in aug_dirs: + L.append((" + " if d in extra else " ") + d) + if extra: + L.append("") + L.append("(+ = added by this app on top of the PATH it inherited. Claude Desktop, " + "launched separately, may NOT have these on its PATH.)") + return "\n".join(L) + + +def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]: + """ + Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx) + counts as reachable; only connection/DNS/timeout failures count as unreachable. + Returns (ok, detail). Network call — run this off the UI thread. + """ + import socket + import urllib.error + import urllib.request + + url = (url or "").strip() + if not (url.startswith("http://") or url.startswith("https://")): + return False, "not an http(s) URL" + req = urllib.request.Request( + url, method="GET", + headers={"Accept": "text/event-stream, application/json, */*", + "User-Agent": "BetterClaudeConfig/1.0"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return True, f"HTTP {r.status}" + except urllib.error.HTTPError as e: + return True, f"HTTP {e.code} (reachable)" + except urllib.error.URLError as e: + reason = getattr(e, "reason", e) + if isinstance(reason, socket.timeout): + return False, "timed out" + return False, str(reason) + except socket.timeout: + return False, "timed out" + except Exception as e: # noqa: BLE001 - surface anything else as a clean message + return False, str(e) + + +def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | None]: + """ + Fix a 'PATH-risk' (warn) server by rewriting the bare runner name to the + absolute path it resolved to, so any process (incl. a bundled Claude.app) + can find it. Returns (new_data, note). note is None if nothing was pinned. + """ + res = check_dependency(data, path) + if res["status"] != "warn": + return data, None + out = dict(data) + for c, resolved in res["resolved"].items(): + if resolved and not res["in_base"].get(c, True) and ("/" not in c and "\\" not in c): + if str(out.get("command", "")) == c: + out["command"] = resolved + return out, f"command → {resolved}" + args = list(out.get("args", []) or []) + for i, a in enumerate(args): + if a == c: + args[i] = resolved + out["args"] = args + return out, f"'{c}' → {resolved}" + return data, None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1966c69 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PySide6>=6.6 diff --git a/test_core.py b/test_core.py new file mode 100644 index 0000000..acffe4c --- /dev/null +++ b/test_core.py @@ -0,0 +1,91 @@ +import json, tempfile, shutil +from pathlib import Path +import bcc_core as c + +tmp = Path(tempfile.mkdtemp()) +ok = [] + +def check(name, cond): + ok.append(cond) + print(("PASS" if cond else "FAIL"), "-", name) + +# ---- 1. discovery (monkeypatch the base dir) ---- +base = tmp / "AppSupport" +for d in ("Claude", "Claude-Work", "NotClaude"): + (base / d).mkdir(parents=True) +(base / "Claude" / c.CONFIG_FILENAME).write_text("{}") +# Claude-Work has no config yet; NotClaude shouldn't match +c.app_support_base = lambda: base +profs = c.discover_profiles() +labels = sorted(p.label for p in profs) +check("discovers Claude + Claude-Work, not NotClaude", labels == ["Claude", "Claude-Work"]) +check("flags missing config file", any(p.label=="Claude-Work" and not p.config_exists for p in profs)) + +# ---- 2. write preserves OTHER keys + order, only touches mcpServers ---- +cfgpath = tmp / "real.json" +original = { + "globalShortcut": "Cmd+Shift+Space", + "mcpServers": {"old": {"command": "node", "args": ["x.js"]}}, + "someOtherTool": {"keep": True}, +} +cfgpath.write_text(json.dumps(original, indent=2)) +cfg = c.load_config(cfgpath) +servers = c.extract_servers(cfg) +# add a new one, disable the old one +servers.append(c.ServerEntry("brave", {"command": "npx", "args": ["-y", "@x/brave"]}, True)) +servers[0].enabled = False +c.apply_servers(cfg, servers) +c.write_config(cfgpath, cfg) +written = json.loads(cfgpath.read_text()) +keys = list(written.keys()) +check("preserved globalShortcut", written.get("globalShortcut") == "Cmd+Shift+Space") +check("preserved someOtherTool", written.get("someOtherTool") == {"keep": True}) +check("disabled server parked in _disabledMcpServers", "old" in written.get("_disabledMcpServers", {})) +check("enabled server in mcpServers", "brave" in written.get("mcpServers", {})) +check("old not in active mcpServers", "old" not in written.get("mcpServers", {})) +check("key order kept (globalShortcut before mcpServers)", keys.index("globalShortcut") < keys.index("mcpServers")) +check("backup created", (cfgpath.parent / c.BACKUP_DIRNAME).is_dir() and any((cfgpath.parent / c.BACKUP_DIRNAME).iterdir())) + +# ---- 3. paste parser, all shapes ---- +full = '{"mcpServers": {"a": {"command": "node", "args": ["a.js"]}}}' +check("parse full config shape", list(c.parse_pasted_json(full)) == ["a"]) +inner = '{"b": {"command": "uvx", "args": ["mcp-server-git"]}}' +check("parse inner block shape", list(c.parse_pasted_json(inner)) == ["b"]) +bare = '{"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]}' +parsed = c.parse_pasted_json(bare) +check("parse bare server + suggests name 'filesystem'", "filesystem" in parsed) +remote = '{"url": "https://mcp.asana.com/sse"}' +check("parse remote, suggests host name", "mcp" in c.parse_pasted_json(remote)) +try: + c.parse_pasted_json('{"junk": 5}') + check("rejects junk", False) +except ValueError: + check("rejects junk", True) + +# ---- 4. validation ---- +bad = [c.ServerEntry("", {"command": "x"}, True), + c.ServerEntry("dup", {"command": "x"}, True), + c.ServerEntry("dup", {"command": "x"}, True), + c.ServerEntry("r", {"url": ""}, True), + c.ServerEntry("nocmd", {"command": ""}, True)] +probs = c.validate_servers(bad) +check("validation catches empty name", any("empty name" in p for p in probs)) +check("validation catches duplicate", any("Duplicate" in p for p in probs)) +check("validation catches missing url", any("no URL" in p for p in probs)) +check("validation catches missing command", any("no command" in p for p in probs)) +check("valid set passes clean", c.validate_servers([c.ServerEntry("good", {"command":"node"}, True)]) == []) + +# ---- 5. dependency check (python3 exists; bogusxyz does not) ---- +r_ok = c.check_dependency({"command": "python3"}) +check("dep check finds python3", r_ok["status"] == "ok") +r_missing = c.check_dependency({"command": "definitely-not-real-binary-xyz"}) +check("dep check flags missing", r_missing["status"] == "missing") +r_remote = c.check_dependency({"url": "https://example.com/mcp"}) +check("dep check marks remote", r_remote["status"] == "remote") +# windows-style wrapper: cmd /c npx ... -> should look past cmd to npx +r_wrap = c.check_dependency({"command": "cmd", "args": ["/c", "definitely-not-real-xyz", "foo"]}) +check("dep check sees through shell wrapper", "definitely-not-real-xyz" in r_wrap["label"]) + +print() +print(f"{sum(ok)}/{len(ok)} passed") +shutil.rmtree(tmp)