feat: lenient JSON repair for pasted MCP snippets
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user