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
+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):