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
pip install -r requirements.txt
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
pip install -r requirements-dev.txt
python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed
pyinstaller bcc.spec
# macOS → dist/BetterClaudeConfig.app
@@ -19,7 +23,7 @@ pyinstaller bcc.spec
# 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
@@ -29,7 +33,7 @@ The codebase is split into two layers:
- `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)
- `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.)
- `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.
- **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.
- **Paste JSON — even broken 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. 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.
- **Copy to ▸** — copy the selected server straight into your *other* install.
- **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
"""
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.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QColor, 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,
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
import bcc_core as core
# --- One-line rebrand: change this to recolor the whole app --------------- #
ACCENT = "#f97316" # warm orange
ACCENT = "#f97316" # warm orange
ACCENT_DIM = "#c2570b"
BG = "#1b1d23"
PANEL = "#23262e"
@@ -230,8 +248,8 @@ class ServerEditor(QFrame):
super().__init__()
self.setObjectName("card")
self._on_change = on_change
self._before_change = before_change # called before any row add/remove in tables
self._extra = {} # preserve unknown server fields verbatim
self._before_change = before_change # called before any row add/remove in tables
self._extra = {} # preserve unknown server fields verbatim
self._loading = False
outer = QVBoxLayout(self)
@@ -271,7 +289,9 @@ class ServerEditor(QFrame):
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.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")
@@ -314,9 +334,9 @@ class ServerEditor(QFrame):
self.setEnabled(False)
def _lbl(self, t):
l = QLabel(t)
l.setObjectName("muted")
return l
lbl = QLabel(t)
lbl.setObjectName("muted")
return lbl
def _build_stdio_page(self):
w = QWidget()
@@ -330,13 +350,16 @@ class ServerEditor(QFrame):
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.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,
before_change=self._before_change)
self.env = KeyValueTable(
"Variable", "Value", on_change=self._emit, before_change=self._before_change
)
v.addWidget(self.env)
return w
@@ -356,8 +379,9 @@ class ServerEditor(QFrame):
self.transport.currentIndexChanged.connect(self._emit)
v.addWidget(self.transport)
v.addWidget(self._lbl("Headers"))
self.headers = KeyValueTable("Header", "Value", on_change=self._emit,
before_change=self._before_change)
self.headers = KeyValueTable(
"Header", "Value", on_change=self._emit, before_change=self._before_change
)
v.addWidget(self.headers)
return w
@@ -479,7 +503,9 @@ class ServerEditor(QFrame):
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()))
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
@@ -520,7 +546,7 @@ class ServerEditor(QFrame):
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.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}")
self.dep_label.setStyleSheet(f"color: {color};")
@@ -531,37 +557,72 @@ class PasteDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Paste MCP JSON")
self.resize(560, 380)
self.resize(560, 420)
self.result_servers: dict[str, dict] | None = None
v = QVBoxLayout(self)
info = QLabel(
"Paste a config snippet from any MCP doc. Accepts a full "
"{\"mcpServers\": {...}} block, just the inner {\"name\": {...}} map, "
"or a single bare server object."
"Paste a config snippet from any MCP doc — it doesn't have to be "
"perfect JSON. Markdown fences, comments, trailing commas, smart "
"quotes, and missing braces are fixed automatically."
)
info.setObjectName("muted")
info.setWordWrap(True)
v.addWidget(info)
self.box = QPlainTextEdit()
self.box.setPlaceholderText('{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}')
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")
self.parse_btn.setEnabled(False)
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
btns.accepted.connect(self._try_parse)
btns.rejected.connect(self.reject)
v.addWidget(btns)
# Live parse-as-you-type, debounced so we don't churn on every keystroke.
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(250)
self._debounce.timeout.connect(self._preview)
self.box.textChanged.connect(self._debounce.start)
def _preview(self):
text = self.box.toPlainText()
if not text.strip():
self.err.setText("")
self.parse_btn.setEnabled(False)
return
try:
servers, notes = core.parse_pasted_json_verbose(text)
except Exception as e:
self.err.setStyleSheet(f"color: {BAD};")
self.err.setText(str(e))
self.parse_btn.setEnabled(False)
return
names = ", ".join(
f"{n} ({core.ServerEntry(n, d).kind})" for n, d in list(servers.items())[:6]
)
if len(servers) > 6:
names += f", +{len(servers) - 6} more"
msg = f"✓ Will add: {names}"
if notes:
msg += "\nAuto-repaired: " + "; ".join(notes)
self.err.setStyleSheet(f"color: {GOOD};")
self.err.setText(msg)
self.parse_btn.setEnabled(True)
def _try_parse(self):
try:
self.result_servers = core.parse_pasted_json(self.box.toPlainText())
self.accept()
except Exception as e:
self.err.setStyleSheet(f"color: {BAD};")
self.err.setText(str(e))
@@ -584,7 +645,7 @@ class MainWindow(QMainWindow):
self._suppress_sel = False
self._focused_table = None
self._row_of_index: dict[int, tuple] = {}
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
central = QWidget()
self.setCentralWidget(central)
@@ -597,8 +658,7 @@ class MainWindow(QMainWindow):
split = QHBoxLayout()
split.setSpacing(12)
split.addWidget(self._build_left(), 3)
self.editor = ServerEditor(on_change=self._editor_changed,
before_change=self._push_undo)
self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
split.addWidget(self.editor, 4)
root.addLayout(split, 1)
@@ -703,8 +763,14 @@ class MainWindow(QMainWindow):
self.paste_btn.clicked.connect(self.paste_json)
self.copy_btn.clicked.connect(self.copy_to_menu)
self.undo_btn.clicked.connect(self._undo)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn,
self.copy_btn, self.undo_btn):
for b in (
self.add_btn,
self.dup_btn,
self.del_btn,
self.paste_btn,
self.copy_btn,
self.undo_btn,
):
bar.addWidget(b)
# Ctrl+Z shortcut
undo_action = QAction(self)
@@ -740,10 +806,8 @@ class MainWindow(QMainWindow):
# --- 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}]
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()
@@ -786,8 +850,9 @@ class MainWindow(QMainWindow):
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."
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
@@ -803,7 +868,11 @@ class MainWindow(QMainWindow):
r = table.rowCount()
table.insertRow(r)
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)
table.setItem(r, 0, on)
name_item = QTableWidgetItem(s.name)
@@ -816,8 +885,10 @@ class MainWindow(QMainWindow):
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
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)
@@ -866,19 +937,25 @@ class MainWindow(QMainWindow):
if s.enabled:
n_a += 1
if st == "missing":
a_miss += 1; a_names.append(s.name)
a_miss += 1
a_names.append(s.name)
elif st == "warn":
a_warn += 1; a_names.append(s.name)
a_warn += 1
a_names.append(s.name)
else:
n_d += 1
if st == "missing":
d_miss += 1; d_names.append(s.name)
d_miss += 1
d_names.append(s.name)
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.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 "")
self.disabled_label.setToolTip(
("Needs attention: " + ", ".join(d_names)) if d_names else ""
)
def _select_in(self, table, row):
self._suppress_sel = True
@@ -958,7 +1035,7 @@ class MainWindow(QMainWindow):
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.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()
@@ -1001,7 +1078,10 @@ class MainWindow(QMainWindow):
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:
if (
QMessageBox.question(self, "Delete server", f"Remove “{name}”?")
!= QMessageBox.StandardButton.Yes
):
return
self._push_undo()
del self.servers[idx]
@@ -1018,9 +1098,10 @@ class MainWindow(QMainWindow):
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
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
@@ -1066,8 +1147,9 @@ class MainWindow(QMainWindow):
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?'
self,
"Exists in destination",
f"{src.name}” already exists in {dest.label}. Overwrite it there?",
)
if ans != QMessageBox.StandardButton.Yes:
return
@@ -1080,7 +1162,9 @@ class MainWindow(QMainWindow):
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.')
self.status.setText(
f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply."
)
# --- save ------------------------------------------------------------ #
def _validate(self) -> bool:
@@ -1129,14 +1213,18 @@ class MainWindow(QMainWindow):
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
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()):
if e.mimeData().hasUrls() and any(
u.toLocalFile().endswith(".json") for u in e.mimeData().urls()
):
e.acceptProposedAction()
def dropEvent(self, e):
@@ -1158,7 +1246,9 @@ class MainWindow(QMainWindow):
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.")
self.status.setText(
f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save."
)
break
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
in its original position.
"""
from __future__ import annotations
import contextlib
import functools
import glob
import json
import os
import re
import shutil
import sys
import tempfile
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
@@ -45,6 +48,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
@dataclass
class Profile:
"""A discovered (or manually added) Claude install location."""
label: str
path: Path
config_exists: bool
@@ -171,10 +175,8 @@ def _make_backup(path: Path) -> Path:
# Prune oldest beyond MAX_BACKUPS
backups = sorted(bdir.glob(f"{path.stem}.*.json"))
for old in backups[:-MAX_BACKUPS]:
try:
with contextlib.suppress(OSError):
old.unlink()
except OSError:
pass
return dest
@@ -210,7 +212,7 @@ def looks_like_server(data) -> bool:
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")
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("@")):
@@ -222,20 +224,295 @@ def suggest_name(data: dict) -> str:
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}:
# --------------------------------------------------------------------------- #
# Lenient JSON repair
#
# 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": {...}}}
2. Inner block only: {"name": {"command": ...}}
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested)
# Word-processor / web artifacts mapped back to ASCII.
_QUOTE_MAP = {
"": '"',
"": '"',
"": '"',
"«": '"',
"»": '"',
"": "'",
"": "'",
"": "'",
}
_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()
if not text:
One careful pass over the text producing (is_string, segment) pairs.
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.")
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):
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():
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')."
f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')."
)
clean[str(name)] = data
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
# --------------------------------------------------------------------------- #
@@ -308,26 +602,30 @@ def system_path() -> str:
dirs: list[str] = []
if sys.platform == "darwin":
for p in ["/etc/paths"] + sorted(glob.glob("/etc/paths.d/*")):
try:
for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]:
with contextlib.suppress(OSError):
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,
# accessible to all processes regardless of shell configuration.
for d in (
"/opt/homebrew/bin", "/opt/homebrew/sbin", # Homebrew (Apple Silicon)
"/usr/local/bin", "/usr/local/sbin", # Homebrew (Intel) / manual installs
"/opt/local/bin", "/opt/local/sbin", # MacPorts
"/opt/homebrew/bin",
"/opt/homebrew/sbin", # Homebrew (Apple Silicon)
"/usr/local/bin",
"/usr/local/sbin", # Homebrew (Intel) / manual installs
"/opt/local/bin",
"/opt/local/sbin", # MacPorts
):
dirs.append(d)
elif os.name == "nt":
try:
import winreg
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"),
]:
try:
@@ -338,19 +636,23 @@ def system_path() -> str:
pass
except ImportError:
pass
dirs.extend(["C:\\Windows\\System32", "C:\\Windows",
"C:\\Windows\\System32\\Wbem",
os.path.expandvars(r"%ProgramFiles%\nodejs")])
dirs.extend(
[
"C:\\Windows\\System32",
"C:\\Windows",
"C:\\Windows\\System32\\Wbem",
os.path.expandvars(r"%ProgramFiles%\nodejs"),
]
)
else: # Linux / other
try:
for line in Path("/etc/environment").read_text().splitlines():
if line.upper().startswith("PATH="):
dirs.extend(line[5:].strip('"\'').split(os.pathsep))
dirs.extend(line[5:].strip("\"'").split(os.pathsep))
except OSError:
pass
for d in ("/usr/local/bin", "/usr/bin", "/bin",
"/usr/sbin", "/sbin", "/snap/bin"):
for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"):
dirs.append(d)
seen, out = set(), []
@@ -384,7 +686,9 @@ def augmented_path() -> str:
str(home / ".volta" / "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":
appdata = os.environ.get("APPDATA", "")
if appdata:
@@ -407,12 +711,12 @@ def refresh_path_cache():
# 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/<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`.",
"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` "
"(or `brew install uv`).",
"(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.",
@@ -428,8 +732,10 @@ def runner_hint(cmd: str) -> str:
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}' 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'."
@@ -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}
"""
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": []}
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": []}
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] = {}
@@ -515,8 +836,16 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
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": ""}
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:
@@ -563,8 +892,10 @@ def diagnostics_text(name: str, data: dict) -> str:
L.append((" + " if d in extra else " ") + d)
if extra:
L.append("")
L.append("(+ = user-specific dirs not in the standard system PATH. "
"Claude Desktop launched from Finder may NOT have these.)")
L.append(
"(+ = user-specific dirs not in the standard system PATH. "
"Claude Desktop launched from Finder may NOT have these.)"
)
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://")):
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"},
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:
@@ -596,9 +930,9 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
if isinstance(reason, socket.timeout):
return False, "timed out"
return False, str(reason)
except socket.timeout:
except TimeoutError:
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)
+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(" ")