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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(" ")
|
||||||
Reference in New Issue
Block a user