feat: lenient JSON repair for pasted MCP snippets
CI / Lint (ruff) (push) Successful in 10s
CI / Tests (py3.10) (push) Successful in 23s
CI / Tests (py3.12) (push) Successful in 10s

Pasted config snippets no longer have to be valid JSON. repair_json_text()
auto-fixes markdown fences, surrounding prose, // /* */ # comments,
trailing and missing commas, smart quotes, single quotes, unquoted keys,
Python/JS literals, and unclosed braces. parse_pasted_json_verbose()
reports every repair applied; the paste dialog now parses as you type
and previews exactly what will be added and what was fixed.

Also includes ruff lint fixes and formatting across bcc.py/bcc_core.py.
This commit is contained in:
AJ
2026-07-01 23:43:39 -04:00
parent 157dad9192
commit 6205c85b61
5 changed files with 737 additions and 117 deletions
+8 -4
View File
@@ -8,10 +8,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Install runtime dependency # Install runtime dependency
pip install -r requirements.txt pip install -r requirements.txt
python bcc.py # run the GUI python bcc.py # run the GUI
python test_core.py # run unit tests (23 tests, no GUI needed)
# Test & lint (no GUI / PySide6 needed — tests only exercise bcc_core)
pip install -r requirements-dev.txt
python -m pytest # unit tests in tests/
ruff check . # lint
ruff format . # format (CI enforces ruff format --check)
# Build a self-contained binary # Build a self-contained binary
pip install -r requirements-dev.txt
python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed
pyinstaller bcc.spec pyinstaller bcc.spec
# macOS → dist/BetterClaudeConfig.app # macOS → dist/BetterClaudeConfig.app
@@ -19,7 +23,7 @@ pyinstaller bcc.spec
# Linux → dist/BetterClaudeConfig # Linux → dist/BetterClaudeConfig
``` ```
Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Build-time: `pyinstaller>=6.0`, `pillow>=10.0`. Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Tooling config (ruff, pytest, project metadata) lives in `pyproject.toml`. CI (`.github/workflows/ci.yml`) runs lint + tests on every push/PR; releases build on tag push (`release.yml`).
## Architecture ## Architecture
@@ -29,7 +33,7 @@ The codebase is split into two layers:
- `Profile` / `ServerEntry` dataclasses (the data model) - `Profile` / `ServerEntry` dataclasses (the data model)
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform) - `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform)
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/` - `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
- `parse_pasted_json()` — accepts three JSON shapes (full config, inner map, or bare server object) - `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
- `check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.) - `check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.)
- `test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread - `test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread
+6 -2
View File
@@ -36,8 +36,12 @@ python bcc.py
other file manually. other file manually.
- **Form-based editing** — name, command, args (one per line), env vars, or for - **Form-based editing** — name, command, args (one per line), env vars, or for
remote servers: URL, transport, and headers. No raw JSON. remote servers: URL, transport, and headers. No raw JSON.
- **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block, - **Paste JSON — even broken JSON** — drop in any snippet from an MCP doc (full
inner map, or a single bare server object); it's parsed and merged. `mcpServers` block, inner map, or a single bare server object); it's parsed
and merged. The paste box parses as you type and auto-repairs the stuff docs
and chat windows love to break: markdown fences, surrounding prose, comments,
trailing or missing commas, smart quotes, single quotes, unquoted keys, and
unclosed braces — and tells you exactly what it fixed before you commit.
- **Drag & drop** a `.json` file onto the window to import servers from it. - **Drag & drop** a `.json` file onto the window to import servers from it.
- **Copy to ▸** — copy the selected server straight into your *other* install. - **Copy to ▸** — copy the selected server straight into your *other* install.
- **Active / Disabled sections** — servers are shown in two labelled lists with - **Active / Disabled sections** — servers are shown in two labelled lists with
+149 -59
View File
@@ -7,25 +7,43 @@ on PATH, backs up before writing, and never touches any other key in your config
Run: python mcp_manager.py Run: python mcp_manager.py
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, QSize, QTimer, Signal, QThread from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QColor, QFont, QGuiApplication from PySide6.QtGui import QAction, QColor, QGuiApplication
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QAbstractItemView,
QLabel, QPushButton, QComboBox, QLineEdit, QPlainTextEdit, QTableWidget, QApplication,
QTableWidgetItem, QHeaderView, QStackedWidget, QFileDialog, QMessageBox, QComboBox,
QDialog, QDialogButtonBox, QAbstractItemView, QInputDialog, QFrame, QDialog,
QSizePolicy, QMenu, QStyle, QDialogButtonBox,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
) )
import bcc_core as core import bcc_core as core
# --- One-line rebrand: change this to recolor the whole app --------------- # # --- One-line rebrand: change this to recolor the whole app --------------- #
ACCENT = "#f97316" # warm orange ACCENT = "#f97316" # warm orange
ACCENT_DIM = "#c2570b" ACCENT_DIM = "#c2570b"
BG = "#1b1d23" BG = "#1b1d23"
PANEL = "#23262e" PANEL = "#23262e"
@@ -230,8 +248,8 @@ class ServerEditor(QFrame):
super().__init__() super().__init__()
self.setObjectName("card") self.setObjectName("card")
self._on_change = on_change self._on_change = on_change
self._before_change = before_change # called before any row add/remove in tables self._before_change = before_change # called before any row add/remove in tables
self._extra = {} # preserve unknown server fields verbatim self._extra = {} # preserve unknown server fields verbatim
self._loading = False self._loading = False
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
@@ -271,7 +289,9 @@ class ServerEditor(QFrame):
self.dep_label = QLabel("") self.dep_label = QLabel("")
self.dep_label.setObjectName("muted") self.dep_label.setObjectName("muted")
self.fix_btn = QPushButton("Use full path ↳") 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.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.clicked.connect(self._pin_path)
self.fix_btn.setVisible(False) self.fix_btn.setVisible(False)
self.test_btn = QPushButton("Test connection") self.test_btn = QPushButton("Test connection")
@@ -314,9 +334,9 @@ class ServerEditor(QFrame):
self.setEnabled(False) self.setEnabled(False)
def _lbl(self, t): def _lbl(self, t):
l = QLabel(t) lbl = QLabel(t)
l.setObjectName("muted") lbl.setObjectName("muted")
return l return lbl
def _build_stdio_page(self): def _build_stdio_page(self):
w = QWidget() w = QWidget()
@@ -330,13 +350,16 @@ class ServerEditor(QFrame):
v.addWidget(self.command) v.addWidget(self.command)
v.addWidget(self._lbl("Arguments (one per line)")) v.addWidget(self._lbl("Arguments (one per line)"))
self.args = QPlainTextEdit() self.args = QPlainTextEdit()
self.args.setPlaceholderText("-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents") self.args.setPlaceholderText(
"-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents"
)
self.args.setMaximumHeight(120) self.args.setMaximumHeight(120)
self.args.textChanged.connect(self._emit) self.args.textChanged.connect(self._emit)
v.addWidget(self.args) v.addWidget(self.args)
v.addWidget(self._lbl("Environment variables")) v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable("Variable", "Value", on_change=self._emit, self.env = KeyValueTable(
before_change=self._before_change) "Variable", "Value", on_change=self._emit, before_change=self._before_change
)
v.addWidget(self.env) v.addWidget(self.env)
return w return w
@@ -356,8 +379,9 @@ class ServerEditor(QFrame):
self.transport.currentIndexChanged.connect(self._emit) self.transport.currentIndexChanged.connect(self._emit)
v.addWidget(self.transport) v.addWidget(self.transport)
v.addWidget(self._lbl("Headers")) v.addWidget(self._lbl("Headers"))
self.headers = KeyValueTable("Header", "Value", on_change=self._emit, self.headers = KeyValueTable(
before_change=self._before_change) "Header", "Value", on_change=self._emit, before_change=self._before_change
)
v.addWidget(self.headers) v.addWidget(self.headers)
return w return w
@@ -479,7 +503,9 @@ class ServerEditor(QFrame):
self.diag_card.setVisible(on) self.diag_card.setVisible(on)
self.details_btn.setText("Details ▾" if on else "Details ▸") self.details_btn.setText("Details ▾" if on else "Details ▸")
if on and self.isEnabled(): if on and self.isEnabled():
self.diag_text.setPlainText(core.diagnostics_text(self.current_name(), self.dump_data())) self.diag_text.setPlainText(
core.diagnostics_text(self.current_name(), self.dump_data())
)
def _recheck(self): def _recheck(self):
core.refresh_path_cache() # re-scan PATH, e.g. after installing a runtime core.refresh_path_cache() # re-scan PATH, e.g. after installing a runtime
@@ -520,7 +546,7 @@ class ServerEditor(QFrame):
color = GOOD if ok else BAD color = GOOD if ok else BAD
self.dep_dot.setText("") self.dep_dot.setText("")
self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;") 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.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}")
self.dep_label.setStyleSheet(f"color: {color};") self.dep_label.setStyleSheet(f"color: {color};")
@@ -531,37 +557,72 @@ class PasteDialog(QDialog):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Paste MCP JSON") self.setWindowTitle("Paste MCP JSON")
self.resize(560, 380) self.resize(560, 420)
self.result_servers: dict[str, dict] | None = None self.result_servers: dict[str, dict] | None = None
v = QVBoxLayout(self) v = QVBoxLayout(self)
info = QLabel( info = QLabel(
"Paste a config snippet from any MCP doc. Accepts a full " "Paste a config snippet from any MCP doc — it doesn't have to be "
"{\"mcpServers\": {...}} block, just the inner {\"name\": {...}} map, " "perfect JSON. Markdown fences, comments, trailing commas, smart "
"or a single bare server object." "quotes, and missing braces are fixed automatically."
) )
info.setObjectName("muted") info.setObjectName("muted")
info.setWordWrap(True) info.setWordWrap(True)
v.addWidget(info) v.addWidget(info)
self.box = QPlainTextEdit() 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}') 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) v.addWidget(self.box, 1)
self.err = QLabel("") self.err = QLabel("")
self.err.setStyleSheet(f"color: {BAD};")
self.err.setWordWrap(True) self.err.setWordWrap(True)
v.addWidget(self.err) v.addWidget(self.err)
btns = QDialogButtonBox() btns = QDialogButtonBox()
self.parse_btn = btns.addButton("Parse & Add", QDialogButtonBox.ButtonRole.AcceptRole) self.parse_btn = btns.addButton("Parse & Add", QDialogButtonBox.ButtonRole.AcceptRole)
self.parse_btn.setObjectName("primary") self.parse_btn.setObjectName("primary")
self.parse_btn.setEnabled(False)
btns.addButton(QDialogButtonBox.StandardButton.Cancel) btns.addButton(QDialogButtonBox.StandardButton.Cancel)
btns.accepted.connect(self._try_parse) btns.accepted.connect(self._try_parse)
btns.rejected.connect(self.reject) btns.rejected.connect(self.reject)
v.addWidget(btns) 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): def _try_parse(self):
try: try:
self.result_servers = core.parse_pasted_json(self.box.toPlainText()) self.result_servers = core.parse_pasted_json(self.box.toPlainText())
self.accept() self.accept()
except Exception as e: except Exception as e:
self.err.setStyleSheet(f"color: {BAD};")
self.err.setText(str(e)) self.err.setText(str(e))
@@ -584,7 +645,7 @@ class MainWindow(QMainWindow):
self._suppress_sel = False self._suppress_sel = False
self._focused_table = None self._focused_table = None
self._row_of_index: dict[int, tuple] = {} self._row_of_index: dict[int, tuple] = {}
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
@@ -597,8 +658,7 @@ class MainWindow(QMainWindow):
split = QHBoxLayout() split = QHBoxLayout()
split.setSpacing(12) split.setSpacing(12)
split.addWidget(self._build_left(), 3) split.addWidget(self._build_left(), 3)
self.editor = ServerEditor(on_change=self._editor_changed, self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
before_change=self._push_undo)
split.addWidget(self.editor, 4) split.addWidget(self.editor, 4)
root.addLayout(split, 1) root.addLayout(split, 1)
@@ -703,8 +763,14 @@ class MainWindow(QMainWindow):
self.paste_btn.clicked.connect(self.paste_json) self.paste_btn.clicked.connect(self.paste_json)
self.copy_btn.clicked.connect(self.copy_to_menu) self.copy_btn.clicked.connect(self.copy_to_menu)
self.undo_btn.clicked.connect(self._undo) self.undo_btn.clicked.connect(self._undo)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, for b in (
self.copy_btn, self.undo_btn): self.add_btn,
self.dup_btn,
self.del_btn,
self.paste_btn,
self.copy_btn,
self.undo_btn,
):
bar.addWidget(b) bar.addWidget(b)
# Ctrl+Z shortcut # Ctrl+Z shortcut
undo_action = QAction(self) undo_action = QAction(self)
@@ -740,10 +806,8 @@ class MainWindow(QMainWindow):
# --- profiles -------------------------------------------------------- # # --- profiles -------------------------------------------------------- #
def reload_profiles(self): def reload_profiles(self):
discovered = core.discover_profiles() discovered = core.discover_profiles()
existing_paths = {str(p.path) for p in self.profiles}
# keep any manually-added custom profiles around # keep any manually-added custom profiles around
customs = [p for p in self.profiles if str(p.path) not in customs = [p for p in self.profiles if str(p.path) not in {str(d.path) for d in discovered}]
{str(d.path) for d in discovered}]
self.profiles = discovered + customs self.profiles = discovered + customs
self.profile_combo.blockSignals(True) self.profile_combo.blockSignals(True)
self.profile_combo.clear() self.profile_combo.clear()
@@ -786,8 +850,9 @@ class MainWindow(QMainWindow):
self.full_config = core.load_config(profile.path) self.full_config = core.load_config(profile.path)
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, "Could not read config", self,
f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install." "Could not read config",
f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install.",
) )
return return
self.current_profile = profile self.current_profile = profile
@@ -803,7 +868,11 @@ class MainWindow(QMainWindow):
r = table.rowCount() r = table.rowCount()
table.insertRow(r) table.insertRow(r)
on = QTableWidgetItem() on = QTableWidgetItem()
on.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) on.setFlags(
Qt.ItemFlag.ItemIsUserCheckable
| Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable
)
on.setCheckState(Qt.CheckState.Checked if s.enabled else Qt.CheckState.Unchecked) on.setCheckState(Qt.CheckState.Checked if s.enabled else Qt.CheckState.Unchecked)
table.setItem(r, 0, on) table.setItem(r, 0, on)
name_item = QTableWidgetItem(s.name) name_item = QTableWidgetItem(s.name)
@@ -816,8 +885,10 @@ class MainWindow(QMainWindow):
type_item.setForeground(QColor(MUTED)) type_item.setForeground(QColor(MUTED))
table.setItem(r, 2, type_item) table.setItem(r, 2, type_item)
dep = core.check_dependency(s.data) dep = core.check_dependency(s.data)
st = QTableWidgetItem(f"{STATUS_GLYPH.get(dep['status'],'')} {dep['label']}") 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 st.setForeground(
QColor(STATUS_COLORS.get(dep["status"], MUTED))
) # status stays colored even when off
table.setItem(r, 3, st) table.setItem(r, 3, st)
self._row_of_index[master_idx] = (table, r) self._row_of_index[master_idx] = (table, r)
@@ -866,19 +937,25 @@ class MainWindow(QMainWindow):
if s.enabled: if s.enabled:
n_a += 1 n_a += 1
if st == "missing": if st == "missing":
a_miss += 1; a_names.append(s.name) a_miss += 1
a_names.append(s.name)
elif st == "warn": elif st == "warn":
a_warn += 1; a_names.append(s.name) a_warn += 1
a_names.append(s.name)
else: else:
n_d += 1 n_d += 1
if st == "missing": if st == "missing":
d_miss += 1; d_names.append(s.name) d_miss += 1
d_names.append(s.name)
elif st == "warn": elif st == "warn":
d_warn += 1; d_names.append(s.name) 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.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.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.setText(self._section_html("⊘ Disabled", n_d, d_miss, d_warn))
self.disabled_label.setToolTip(("Needs attention: " + ", ".join(d_names)) if d_names else "") self.disabled_label.setToolTip(
("Needs attention: " + ", ".join(d_names)) if d_names else ""
)
def _select_in(self, table, row): def _select_in(self, table, row):
self._suppress_sel = True self._suppress_sel = True
@@ -958,7 +1035,7 @@ class MainWindow(QMainWindow):
table.item(row, 2).setText("remote" if entry.kind == "remote" else "local") table.item(row, 2).setText("remote" if entry.kind == "remote" else "local")
dep = core.check_dependency(entry.data) dep = core.check_dependency(entry.data)
st = table.item(row, 3) st = table.item(row, 3)
st.setText(f"{STATUS_GLYPH.get(dep['status'],'')} {dep['label']}") st.setText(f"{STATUS_GLYPH.get(dep['status'], '')} {dep['label']}")
st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED))) st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED)))
self._suppress_table = False self._suppress_table = False
self._refresh_badges() self._refresh_badges()
@@ -1001,7 +1078,10 @@ class MainWindow(QMainWindow):
if not (0 <= idx < len(self.servers)): if not (0 <= idx < len(self.servers)):
return return
name = self.servers[idx].name name = self.servers[idx].name
if QMessageBox.question(self, "Delete server", f'Remove “{name}”?') != QMessageBox.StandardButton.Yes: if (
QMessageBox.question(self, "Delete server", f"Remove “{name}”?")
!= QMessageBox.StandardButton.Yes
):
return return
self._push_undo() self._push_undo()
del self.servers[idx] del self.servers[idx]
@@ -1018,9 +1098,10 @@ class MainWindow(QMainWindow):
for name, data in dlg.result_servers.items(): for name, data in dlg.result_servers.items():
if name in existing: if name in existing:
ans = QMessageBox.question( ans = QMessageBox.question(
self, "Server exists", self,
f'{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)', "Server exists",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No f"{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if ans == QMessageBox.StandardButton.Yes: if ans == QMessageBox.StandardButton.Yes:
self.servers[existing[name]].data = data self.servers[existing[name]].data = data
@@ -1066,8 +1147,9 @@ class MainWindow(QMainWindow):
names = {s.name for s in existing} names = {s.name for s in existing}
if src.name in names: if src.name in names:
ans = QMessageBox.question( ans = QMessageBox.question(
self, "Exists in destination", self,
f'{src.name}” already exists in {dest.label}. Overwrite it there?' "Exists in destination",
f"{src.name}” already exists in {dest.label}. Overwrite it there?",
) )
if ans != QMessageBox.StandardButton.Yes: if ans != QMessageBox.StandardButton.Yes:
return return
@@ -1080,7 +1162,9 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}") QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}")
return return
bnote = f" (backup: {backup.name})" if backup else "" bnote = f" (backup: {backup.name})" if backup else ""
self.status.setText(f'Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply.') self.status.setText(
f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply."
)
# --- save ------------------------------------------------------------ # # --- save ------------------------------------------------------------ #
def _validate(self) -> bool: def _validate(self) -> bool:
@@ -1129,14 +1213,18 @@ class MainWindow(QMainWindow):
self.status.setText(f"{self.current_profile.path} · {on}/{n} enabled{d}") self.status.setText(f"{self.current_profile.path} · {on}/{n} enabled{d}")
def _confirm_discard(self) -> bool: def _confirm_discard(self) -> bool:
return QMessageBox.question( return (
self, "Discard changes?", QMessageBox.question(
"You have unsaved changes. Discard them?" self, "Discard changes?", "You have unsaved changes. Discard them?"
) == QMessageBox.StandardButton.Yes )
== QMessageBox.StandardButton.Yes
)
# --- drag & drop a .json file to import ------------------------------ # # --- drag & drop a .json file to import ------------------------------ #
def dragEnterEvent(self, e): def dragEnterEvent(self, e):
if e.mimeData().hasUrls() and any(u.toLocalFile().endswith(".json") for u in e.mimeData().urls()): if e.mimeData().hasUrls() and any(
u.toLocalFile().endswith(".json") for u in e.mimeData().urls()
):
e.acceptProposedAction() e.acceptProposedAction()
def dropEvent(self, e): def dropEvent(self, e):
@@ -1158,7 +1246,9 @@ class MainWindow(QMainWindow):
self.servers.append(core.ServerEntry(name, data, True)) self.servers.append(core.ServerEntry(name, data, True))
self._refresh_tables(select_index=len(self.servers) - 1) self._refresh_tables(select_index=len(self.servers) - 1)
self._mark_dirty() self._mark_dirty()
self.status.setText(f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save.") self.status.setText(
f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save."
)
break break
def closeEvent(self, e): def closeEvent(self, e):
+386 -52
View File
@@ -10,17 +10,20 @@ 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 key). Every other top-level key in the user's config is preserved verbatim and
in its original position. in its original position.
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import functools import functools
import glob import glob
import json import json
import os import os
import re
import shutil import shutil
import sys import sys
import tempfile import tempfile
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -45,6 +48,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
@dataclass @dataclass
class Profile: class Profile:
"""A discovered (or manually added) Claude install location.""" """A discovered (or manually added) Claude install location."""
label: str label: str
path: Path path: Path
config_exists: bool config_exists: bool
@@ -171,10 +175,8 @@ def _make_backup(path: Path) -> Path:
# Prune oldest beyond MAX_BACKUPS # Prune oldest beyond MAX_BACKUPS
backups = sorted(bdir.glob(f"{path.stem}.*.json")) backups = sorted(bdir.glob(f"{path.stem}.*.json"))
for old in backups[:-MAX_BACKUPS]: for old in backups[:-MAX_BACKUPS]:
try: with contextlib.suppress(OSError):
old.unlink() old.unlink()
except OSError:
pass
return dest return dest
@@ -210,7 +212,7 @@ def looks_like_server(data) -> bool:
def suggest_name(data: dict) -> str: def suggest_name(data: dict) -> str:
"""Derive a friendly default name from a server definition.""" """Derive a friendly default name from a server definition."""
if "url" in data and "command" not in data: if "url" in data and "command" not in data:
host = (urlparse(data["url"]).hostname or "remote") host = urlparse(data["url"]).hostname or "remote"
return host.split(".")[0] or "remote" return host.split(".")[0] or "remote"
for a in data.get("args", []) or []: for a in data.get("args", []) or []:
if isinstance(a, str) and ("/" in a or a.startswith("@")): if isinstance(a, str) and ("/" in a or a.startswith("@")):
@@ -222,20 +224,295 @@ def suggest_name(data: dict) -> str:
return Path(str(cmd)).stem or "server" return Path(str(cmd)).stem or "server"
def parse_pasted_json(text: str) -> dict[str, dict]: # --------------------------------------------------------------------------- #
""" # Lenient JSON repair
Accept any of the shapes MCP docs hand out and return {name: server_dict}: #
# Snippets pasted from MCP docs, blog posts, and chat windows are frequently
# not valid JSON: markdown fences, surrounding prose, // comments, trailing
# commas, smart quotes, single quotes, unquoted keys, missing braces. The
# whole point of BCC is that nobody should have to hand-fix JSON, so the
# paste pipeline repairs what it can and reports what it changed.
# --------------------------------------------------------------------------- #
1. Full config: {"mcpServers": {"name": {...}}} # Word-processor / web artifacts mapped back to ASCII.
2. Inner block only: {"name": {"command": ...}} _QUOTE_MAP = {
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested) "": '"',
"": '"',
"": '"',
"«": '"',
"»": '"',
"": "'",
"": "'",
"": "'",
}
_JUNK_CHARS = "" # zero-width chars / BOM
Raises ValueError with a human message on anything unrecognizable.
def _strip_fences(text: str, notes: list[str]) -> str:
"""Pull the contents out of a ```json ... ``` fence (or drop stray fence lines)."""
m = re.search(r"```(?:json[c5]?|javascript|js)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE)
if m:
notes.append("extracted code from markdown fence")
return m.group(1)
if "```" in text:
notes.append("removed markdown fence markers")
return text.replace("```json", "").replace("```", "")
return text
def _normalize_unicode(text: str, notes: list[str]) -> str:
out = text
for junk in _JUNK_CHARS:
out = out.replace(junk, "")
out = out.replace(" ", " ") # non-breaking space
for smart, ascii_q in _QUOTE_MAP.items():
out = out.replace(smart, ascii_q)
if out != text:
notes.append("normalized smart quotes / invisible characters")
return out
def _tokenize(text: str, notes: list[str]) -> list[tuple[bool, str]]:
""" """
text = (text or "").strip() One careful pass over the text producing (is_string, segment) pairs.
if not text: While walking it also: converts single-quoted strings to double-quoted,
and drops //, /* */ and # comments (never inside strings).
"""
segs: list[tuple[bool, str]] = []
buf: list[str] = []
i, n = 0, len(text)
def flush():
if buf:
segs.append((False, "".join(buf)))
buf.clear()
while i < n:
ch = text[i]
if ch == '"': # proper double-quoted string: copy verbatim
flush()
j = i + 1
s = ['"']
while j < n:
c = text[j]
s.append(c)
if c == "\\" and j + 1 < n:
s.append(text[j + 1])
j += 2
continue
if c == '"':
j += 1
break
j += 1
segs.append((True, "".join(s)))
i = j
elif ch == "'": # single-quoted string: convert to double-quoted
flush()
j = i + 1
inner: list[str] = []
while j < n:
c = text[j]
if c == "\\" and j + 1 < n:
nxt = text[j + 1]
if nxt == "'":
inner.append("'") # \' has no meaning in JSON
else:
inner.append(c)
inner.append(nxt)
j += 2
continue
if c == "'":
j += 1
break
if c == '"':
inner.append('\\"')
j += 1
continue
inner.append(c)
j += 1
segs.append((True, '"' + "".join(inner) + '"'))
notes.append("converted single-quoted strings")
i = j
elif ch == "/" and i + 1 < n and text[i + 1] == "/":
notes.append("removed // comments")
while i < n and text[i] != "\n":
i += 1
elif ch == "/" and i + 1 < n and text[i + 1] == "*":
notes.append("removed /* */ comments")
i += 2
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i = min(i + 2, n)
elif ch == "#":
# Only treat as a comment at line start (after whitespace) — never
# mid-value, where # could be part of an unquoted token.
line_start = text.rfind("\n", 0, i) + 1
if text[line_start:i].strip() == "":
notes.append("removed # comments")
while i < n and text[i] != "\n":
i += 1
else:
buf.append(ch)
i += 1
else:
buf.append(ch)
i += 1
flush()
return segs
def _repair_segments(segs: list[tuple[bool, str]], notes: list[str]) -> str:
"""Structural fixes that only apply outside strings."""
fixed: list[str] = []
for idx, (is_str, seg) in enumerate(segs):
if is_str:
fixed.append(seg)
continue
s = seg
# Python / JS literals -> JSON
s2 = re.sub(r"\bTrue\b", "true", s)
s2 = re.sub(r"\bFalse\b", "false", s2)
s2 = re.sub(r"\bNone\b|\bundefined\b", "null", s2)
if s2 != s:
notes.append("converted Python/JS literals (True/False/None)")
s = s2
# Unquoted keys: { key: or , key: -> "key":
s2 = re.sub(r"([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*:)", r'\1"\2"\3', s)
if s2 != s:
notes.append("quoted bare object keys")
s = s2
# Missing comma before the next string on a new line, e.g.
# "args": ["x"]\n"env": {...} or {...}\n"name2": {...}
# A string directly after a value-ish ending across a newline can never
# be valid JSON without a comma, so inserting one is always safe.
if idx + 1 < len(segs) and segs[idx + 1][0]:
body = s.rstrip()
tail_ws = s[len(body) :]
prev = body[-1:] if body else (fixed[-1].rstrip()[-1:] if fixed else "")
value_ending = prev in ('"', "}", "]") or prev.isdigit()
if "\n" in tail_ws and value_ending:
notes.append("inserted missing commas")
s = body + "," + tail_ws
fixed.append(s)
out = "".join(fixed)
# Trailing commas (safe now: strings are intact, commas here are structural)
segs2 = _tokenize(out, [])
parts: list[str] = []
changed = False
for is_str, seg in segs2:
if is_str:
parts.append(seg)
else:
new = re.sub(r",(\s*[}\]])", r"\1", seg)
changed = changed or new != seg
parts.append(new)
if changed:
notes.append("removed trailing commas")
return "".join(parts)
def _extract_and_balance(text: str, notes: list[str]) -> str:
"""Slice out the JSON object (dropping surrounding prose) and close any
unclosed braces/brackets."""
start = text.find("{")
if start == -1:
return text
if text[:start].strip():
notes.append("ignored text before the JSON block")
stack: list[str] = []
i, n = start, len(text)
end = -1
while i < n:
ch = text[i]
if ch == '"': # skip strings
i += 1
while i < n:
if text[i] == "\\":
i += 2
continue
if text[i] == '"':
break
i += 1
elif ch in "{[":
stack.append("}" if ch == "{" else "]")
elif ch in "}]":
if stack and stack[-1] == ch:
stack.pop()
if not stack:
end = i + 1
break
i += 1
if end != -1:
if text[end:].strip():
notes.append("ignored text after the JSON block")
return text[start:end]
# Ran out of input with open scopes: close them.
if stack:
notes.append("closed unclosed braces/brackets")
return text[start:] + "".join(reversed(stack))
return text[start:]
def repair_json_text(text: str) -> tuple[str, list[str]]:
"""
Best-effort repair of an almost-JSON snippet. Returns (candidate, notes)
where notes is a human-readable list of the fixes applied (deduplicated,
in order). Does NOT guarantee the result parses — callers still try
json.loads and surface its error if repair wasn't enough.
"""
notes: list[str] = []
t = _strip_fences(text, notes)
t = _normalize_unicode(t, notes)
t = t.strip()
# Brace-less inner fragment: "name": { ... } (with no outer braces).
# A quoted key at the start is a strong signal; a bare word only counts
# when followed by '{' so prose like "Note: ..." isn't swallowed.
if re.match(r'^\s*"[^"\n]+"\s*:\s*[{["]', t) or re.match(r"^\s*[A-Za-z_$][\w$.-]*\s*:\s*\{", t):
notes.append("wrapped fragment in braces")
t = "{" + t + "}"
t = _extract_and_balance(t, notes)
segs = _tokenize(t, notes)
t = _repair_segments(segs, notes)
# Re-balance in case comment/quote fixes exposed structure.
t = _extract_and_balance(t, [])
seen: set[str] = set()
unique = [x for x in notes if not (x in seen or seen.add(x))]
return t, unique
def parse_pasted_json_verbose(text: str) -> tuple[dict[str, dict], list[str]]:
"""
Like parse_pasted_json, but forgiving. Tries strict JSON first; if that
fails, runs repair_json_text() and retries. Returns (servers, repair_notes).
repair_notes is empty when the input was already valid.
"""
raw = (text or "").strip()
if not raw:
raise ValueError("Nothing to parse.") raise ValueError("Nothing to parse.")
obj = json.loads(text) try:
return _shape_servers(json.loads(raw)), []
except json.JSONDecodeError as strict_err:
candidate, notes = repair_json_text(raw)
try:
obj = json.loads(candidate)
except json.JSONDecodeError:
# Repair wasn't enough — report the original, more meaningful error.
raise ValueError(
f"Couldn't parse that as JSON even after auto-repair "
f"(line {strict_err.lineno}, column {strict_err.colno}: {strict_err.msg})."
) from strict_err
return _shape_servers(obj), notes
def _shape_servers(obj) -> dict[str, dict]:
"""Shared shape-detection for parsed paste content."""
if not isinstance(obj, dict): if not isinstance(obj, dict):
raise ValueError("Top-level JSON must be an object ({ ... }).") raise ValueError("Top-level JSON must be an object ({ ... }).")
@@ -252,13 +529,30 @@ def parse_pasted_json(text: str) -> dict[str, dict]:
for name, data in servers.items(): for name, data in servers.items():
if not looks_like_server(data): if not looks_like_server(data):
raise ValueError( raise ValueError(
f"'{name}' doesn't look like an MCP server " f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')."
f"(it needs a 'command' or a 'url')."
) )
clean[str(name)] = data clean[str(name)] = data
return clean return clean
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)
Input doesn't have to be valid JSON — markdown fences, comments, trailing
commas, smart/single quotes, unquoted keys, surrounding prose, and missing
braces are repaired automatically (see repair_json_text).
Raises ValueError with a human message on anything unrecognizable.
"""
servers, _notes = parse_pasted_json_verbose(text)
return servers
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Validation # Validation
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -308,26 +602,30 @@ def system_path() -> str:
dirs: list[str] = [] dirs: list[str] = []
if sys.platform == "darwin": if sys.platform == "darwin":
for p in ["/etc/paths"] + sorted(glob.glob("/etc/paths.d/*")): for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]:
try: with contextlib.suppress(OSError):
dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip()) dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip())
except OSError:
pass
# Package-manager install prefixes: present on disk once installed, # Package-manager install prefixes: present on disk once installed,
# accessible to all processes regardless of shell configuration. # accessible to all processes regardless of shell configuration.
for d in ( for d in (
"/opt/homebrew/bin", "/opt/homebrew/sbin", # Homebrew (Apple Silicon) "/opt/homebrew/bin",
"/usr/local/bin", "/usr/local/sbin", # Homebrew (Intel) / manual installs "/opt/homebrew/sbin", # Homebrew (Apple Silicon)
"/opt/local/bin", "/opt/local/sbin", # MacPorts "/usr/local/bin",
"/usr/local/sbin", # Homebrew (Intel) / manual installs
"/opt/local/bin",
"/opt/local/sbin", # MacPorts
): ):
dirs.append(d) dirs.append(d)
elif os.name == "nt": elif os.name == "nt":
try: try:
import winreg import winreg
for hive, sub in [ for hive, sub in [
(winreg.HKEY_LOCAL_MACHINE, (
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"), winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
),
(winreg.HKEY_CURRENT_USER, r"Environment"), (winreg.HKEY_CURRENT_USER, r"Environment"),
]: ]:
try: try:
@@ -338,19 +636,23 @@ def system_path() -> str:
pass pass
except ImportError: except ImportError:
pass pass
dirs.extend(["C:\\Windows\\System32", "C:\\Windows", dirs.extend(
"C:\\Windows\\System32\\Wbem", [
os.path.expandvars(r"%ProgramFiles%\nodejs")]) "C:\\Windows\\System32",
"C:\\Windows",
"C:\\Windows\\System32\\Wbem",
os.path.expandvars(r"%ProgramFiles%\nodejs"),
]
)
else: # Linux / other else: # Linux / other
try: try:
for line in Path("/etc/environment").read_text().splitlines(): for line in Path("/etc/environment").read_text().splitlines():
if line.upper().startswith("PATH="): if line.upper().startswith("PATH="):
dirs.extend(line[5:].strip('"\'').split(os.pathsep)) dirs.extend(line[5:].strip("\"'").split(os.pathsep))
except OSError: except OSError:
pass pass
for d in ("/usr/local/bin", "/usr/bin", "/bin", for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"):
"/usr/sbin", "/sbin", "/snap/bin"):
dirs.append(d) dirs.append(d)
seen, out = set(), [] seen, out = set(), []
@@ -384,7 +686,9 @@ def augmented_path() -> str:
str(home / ".volta" / "bin"), str(home / ".volta" / "bin"),
] ]
user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin")) user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin"))
user_dirs += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin")) user_dirs += glob.glob(
str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin")
)
if os.name == "nt": if os.name == "nt":
appdata = os.environ.get("APPDATA", "") appdata = os.environ.get("APPDATA", "")
if appdata: if appdata:
@@ -407,12 +711,12 @@ def refresh_path_cache():
# Tailored install advice keyed by the runner's basename. # Tailored install advice keyed by the runner's basename.
RUNNER_HINTS = { RUNNER_HINTS = {
"npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. " "npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. "
"With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.", "With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.",
"node": "Node.js not found. Install from nodejs.org or `brew install node`.", "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` " "uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
"(or `brew install uv`). uvx ships inside uv.", "(or `brew install uv`). uvx ships inside uv.",
"uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " "uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
"(or `brew install uv`).", "(or `brew install uv`).",
"python": "Python not found on this PATH.", "python": "Python not found on this PATH.",
"python3": "Python 3 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.", "docker": "Docker not found. Install Docker Desktop and make sure it's running.",
@@ -428,8 +732,10 @@ def runner_hint(cmd: str) -> str:
if base in RUNNER_HINTS: if base in RUNNER_HINTS:
return RUNNER_HINTS[base] return RUNNER_HINTS[base]
if ("/" in cmd) or ("\\" in cmd): if ("/" in cmd) or ("\\" in cmd):
return (f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. " return (
f"Check the path and that it's marked executable (chmod +x).") 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'." return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'."
@@ -466,14 +772,29 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail} Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail}
""" """
if "url" in data and "command" not in data: if "url" in data and "command" not in data:
return {"status": "remote", "label": "remote endpoint", "detail": data.get("url", ""), return {
"resolved": {}, "in_base": {}, "searched_path": "", "hints": [], "commands": []} "status": "remote",
"label": "remote endpoint",
"detail": data.get("url", ""),
"resolved": {},
"in_base": {},
"searched_path": "",
"hints": [],
"commands": [],
}
cmds = _commands_to_check(data) cmds = _commands_to_check(data)
if not cmds: if not cmds:
return {"status": "unknown", "label": "no command set", "detail": "", return {
"resolved": {}, "in_base": {}, "searched_path": "", "status": "unknown",
"hints": ["This server has no 'command' to run."], "commands": []} "label": "no command set",
"detail": "",
"resolved": {},
"in_base": {},
"searched_path": "",
"hints": ["This server has no 'command' to run."],
"commands": [],
}
aug = path or augmented_path() aug = path or augmented_path()
resolved: dict[str, str | None] = {} resolved: dict[str, str | None] = {}
@@ -515,8 +836,16 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
status = "ok" status = "ok"
label = "found: " + ", ".join(Path(p).name for p in resolved.values()) label = "found: " + ", ".join(Path(p).name for p in resolved.values())
return {"status": status, "label": label, "resolved": resolved, "in_base": in_base, return {
"searched_path": aug, "hints": hints, "commands": cmds, "detail": ""} "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: def diagnostics_text(name: str, data: dict) -> str:
@@ -563,8 +892,10 @@ def diagnostics_text(name: str, data: dict) -> str:
L.append((" + " if d in extra else " ") + d) L.append((" + " if d in extra else " ") + d)
if extra: if extra:
L.append("") L.append("")
L.append("(+ = user-specific dirs not in the standard system PATH. " L.append(
"Claude Desktop launched from Finder may NOT have these.)") "(+ = user-specific dirs not in the standard system PATH. "
"Claude Desktop launched from Finder may NOT have these.)"
)
return "\n".join(L) return "\n".join(L)
@@ -582,9 +913,12 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
if not (url.startswith("http://") or url.startswith("https://")): if not (url.startswith("http://") or url.startswith("https://")):
return False, "not an http(s) URL" return False, "not an http(s) URL"
req = urllib.request.Request( req = urllib.request.Request(
url, method="GET", url,
headers={"Accept": "text/event-stream, application/json, */*", method="GET",
"User-Agent": "BetterClaudeConfig/1.0"}, headers={
"Accept": "text/event-stream, application/json, */*",
"User-Agent": "BetterClaudeConfig/1.0",
},
) )
try: try:
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
@@ -596,9 +930,9 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
if isinstance(reason, socket.timeout): if isinstance(reason, socket.timeout):
return False, "timed out" return False, "timed out"
return False, str(reason) return False, str(reason)
except socket.timeout: except TimeoutError:
return False, "timed out" return False, "timed out"
except Exception as e: # noqa: BLE001 - surface anything else as a clean message except Exception as e:
return False, str(e) return False, str(e)
+188
View File
@@ -0,0 +1,188 @@
"""Tests for the lenient JSON repair pipeline (repair_json_text +
parse_pasted_json_verbose). Every case here is a shape people actually paste
from MCP docs, blog posts, and chat windows."""
import pytest
import bcc_core as c
GOOD = {"command": "npx", "args": ["-y", "@x/brave"]}
def assert_brave(servers):
assert "brave" in servers
assert servers["brave"]["command"] == "npx"
# --------------------------------------------------------------------------- #
# Valid JSON passes through untouched
# --------------------------------------------------------------------------- #
def test_strict_json_no_notes():
servers, notes = c.parse_pasted_json_verbose('{"brave": {"command": "npx", "args": []}}')
assert_brave(servers)
assert notes == []
# --------------------------------------------------------------------------- #
# Markdown / prose wrappers
# --------------------------------------------------------------------------- #
def test_markdown_fence():
text = 'Add this to your config:\n```json\n{"brave": {"command": "npx"}}\n```\nThen restart.'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("fence" in n for n in notes)
def test_fence_without_language_tag():
text = '```\n{"brave": {"command": "npx"}}\n```'
servers, _ = c.parse_pasted_json_verbose(text)
assert_brave(servers)
def test_prose_around_bare_json():
text = 'Paste the following: {"brave": {"command": "npx"}} and restart Claude.'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("before" in n or "after" in n for n in notes)
# --------------------------------------------------------------------------- #
# Comments
# --------------------------------------------------------------------------- #
def test_line_comments():
text = '{\n // the search server\n "brave": {"command": "npx"}\n}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("//" in n for n in notes)
def test_block_comments():
text = '{ /* config */ "brave": {"command": "npx"} }'
servers, _ = c.parse_pasted_json_verbose(text)
assert_brave(servers)
def test_url_with_double_slash_is_not_a_comment():
text = '{"remote": {"url": "https://mcp.example.com/sse"}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert servers["remote"]["url"] == "https://mcp.example.com/sse"
assert notes == []
# --------------------------------------------------------------------------- #
# Commas
# --------------------------------------------------------------------------- #
def test_trailing_comma_object():
text = '{"brave": {"command": "npx", "args": ["-y"],},}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("trailing" in n for n in notes)
def test_missing_comma_between_servers():
text = '{"brave": {"command": "npx"}\n"git": {"command": "uvx"}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert "brave" in servers and "git" in servers
assert any("missing comma" in n for n in notes)
def test_missing_comma_between_string_fields():
text = '{"brave": {"command": "npx"\n"args": ["-y"]}}'
servers, _ = c.parse_pasted_json_verbose(text)
assert servers["brave"]["args"] == ["-y"]
# --------------------------------------------------------------------------- #
# Quotes
# --------------------------------------------------------------------------- #
def test_smart_quotes():
text = "{“brave”: {“command”: “npx”}}"
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("smart quotes" in n for n in notes)
def test_single_quotes():
text = "{'brave': {'command': 'npx', 'args': ['-y']}}"
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("single-quoted" in n for n in notes)
def test_single_quotes_with_inner_double():
text = """{'brave': {'command': 'npx', 'env': {'NOTE': 'say "hi"'}}}"""
servers, _ = c.parse_pasted_json_verbose(text)
assert servers["brave"]["env"]["NOTE"] == 'say "hi"'
# --------------------------------------------------------------------------- #
# JS-style laxness
# --------------------------------------------------------------------------- #
def test_unquoted_keys():
text = '{brave: {command: "npx", args: ["-y"]}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("bare object keys" in n for n in notes)
def test_python_literals():
text = '{"brave": {"command": "npx", "enabled": True, "extra": None}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert servers["brave"]["enabled"] is True
assert servers["brave"]["extra"] is None
assert any("literals" in n for n in notes)
# --------------------------------------------------------------------------- #
# Structure damage
# --------------------------------------------------------------------------- #
def test_braceless_fragment():
text = '"brave": {"command": "npx", "args": ["-y"]}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("wrapped" in n for n in notes)
def test_missing_closing_braces():
text = '{"mcpServers": {"brave": {"command": "npx", "args": ["-y"]'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("unclosed" in n for n in notes)
def test_kitchen_sink():
# fence + comment + single quotes + unquoted key + trailing comma + prose
text = (
"Here's the config you need:\n"
"```json\n"
"{\n"
" // brave search\n"
" brave: {\n"
" 'command': 'npx',\n"
" 'args': ['-y', '@x/brave',],\n"
" },\n"
"}\n"
"```\n"
)
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert servers["brave"]["args"] == ["-y", "@x/brave"]
assert len(notes) >= 4
# --------------------------------------------------------------------------- #
# Still fails cleanly on hopeless input
# --------------------------------------------------------------------------- #
def test_hopeless_input_raises_value_error():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose("this is just prose with no json at all")
def test_junk_object_still_rejected():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose('{"junk": 5}')
def test_empty_raises():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose(" ")