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
+386 -52
View File
@@ -10,17 +10,20 @@ ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking
key). Every other top-level key in the user's config is preserved verbatim and
in its original position.
"""
from __future__ import annotations
import contextlib
import functools
import glob
import json
import os
import re
import shutil
import sys
import tempfile
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
@@ -45,6 +48,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
@dataclass
class Profile:
"""A discovered (or manually added) Claude install location."""
label: str
path: Path
config_exists: bool
@@ -171,10 +175,8 @@ def _make_backup(path: Path) -> Path:
# Prune oldest beyond MAX_BACKUPS
backups = sorted(bdir.glob(f"{path.stem}.*.json"))
for old in backups[:-MAX_BACKUPS]:
try:
with contextlib.suppress(OSError):
old.unlink()
except OSError:
pass
return dest
@@ -210,7 +212,7 @@ def looks_like_server(data) -> bool:
def suggest_name(data: dict) -> str:
"""Derive a friendly default name from a server definition."""
if "url" in data and "command" not in data:
host = (urlparse(data["url"]).hostname or "remote")
host = urlparse(data["url"]).hostname or "remote"
return host.split(".")[0] or "remote"
for a in data.get("args", []) or []:
if isinstance(a, str) and ("/" in a or a.startswith("@")):
@@ -222,20 +224,295 @@ def suggest_name(data: dict) -> str:
return Path(str(cmd)).stem or "server"
def parse_pasted_json(text: str) -> dict[str, dict]:
"""
Accept any of the shapes MCP docs hand out and return {name: server_dict}:
# --------------------------------------------------------------------------- #
# Lenient JSON repair
#
# Snippets pasted from MCP docs, blog posts, and chat windows are frequently
# not valid JSON: markdown fences, surrounding prose, // comments, trailing
# commas, smart quotes, single quotes, unquoted keys, missing braces. The
# whole point of BCC is that nobody should have to hand-fix JSON, so the
# paste pipeline repairs what it can and reports what it changed.
# --------------------------------------------------------------------------- #
1. Full config: {"mcpServers": {"name": {...}}}
2. Inner block only: {"name": {"command": ...}}
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested)
# Word-processor / web artifacts mapped back to ASCII.
_QUOTE_MAP = {
"": '"',
"": '"',
"": '"',
"«": '"',
"»": '"',
"": "'",
"": "'",
"": "'",
}
_JUNK_CHARS = "" # zero-width chars / BOM
Raises ValueError with a human message on anything unrecognizable.
def _strip_fences(text: str, notes: list[str]) -> str:
"""Pull the contents out of a ```json ... ``` fence (or drop stray fence lines)."""
m = re.search(r"```(?:json[c5]?|javascript|js)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE)
if m:
notes.append("extracted code from markdown fence")
return m.group(1)
if "```" in text:
notes.append("removed markdown fence markers")
return text.replace("```json", "").replace("```", "")
return text
def _normalize_unicode(text: str, notes: list[str]) -> str:
out = text
for junk in _JUNK_CHARS:
out = out.replace(junk, "")
out = out.replace(" ", " ") # non-breaking space
for smart, ascii_q in _QUOTE_MAP.items():
out = out.replace(smart, ascii_q)
if out != text:
notes.append("normalized smart quotes / invisible characters")
return out
def _tokenize(text: str, notes: list[str]) -> list[tuple[bool, str]]:
"""
text = (text or "").strip()
if not text:
One careful pass over the text producing (is_string, segment) pairs.
While walking it also: converts single-quoted strings to double-quoted,
and drops //, /* */ and # comments (never inside strings).
"""
segs: list[tuple[bool, str]] = []
buf: list[str] = []
i, n = 0, len(text)
def flush():
if buf:
segs.append((False, "".join(buf)))
buf.clear()
while i < n:
ch = text[i]
if ch == '"': # proper double-quoted string: copy verbatim
flush()
j = i + 1
s = ['"']
while j < n:
c = text[j]
s.append(c)
if c == "\\" and j + 1 < n:
s.append(text[j + 1])
j += 2
continue
if c == '"':
j += 1
break
j += 1
segs.append((True, "".join(s)))
i = j
elif ch == "'": # single-quoted string: convert to double-quoted
flush()
j = i + 1
inner: list[str] = []
while j < n:
c = text[j]
if c == "\\" and j + 1 < n:
nxt = text[j + 1]
if nxt == "'":
inner.append("'") # \' has no meaning in JSON
else:
inner.append(c)
inner.append(nxt)
j += 2
continue
if c == "'":
j += 1
break
if c == '"':
inner.append('\\"')
j += 1
continue
inner.append(c)
j += 1
segs.append((True, '"' + "".join(inner) + '"'))
notes.append("converted single-quoted strings")
i = j
elif ch == "/" and i + 1 < n and text[i + 1] == "/":
notes.append("removed // comments")
while i < n and text[i] != "\n":
i += 1
elif ch == "/" and i + 1 < n and text[i + 1] == "*":
notes.append("removed /* */ comments")
i += 2
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i = min(i + 2, n)
elif ch == "#":
# Only treat as a comment at line start (after whitespace) — never
# mid-value, where # could be part of an unquoted token.
line_start = text.rfind("\n", 0, i) + 1
if text[line_start:i].strip() == "":
notes.append("removed # comments")
while i < n and text[i] != "\n":
i += 1
else:
buf.append(ch)
i += 1
else:
buf.append(ch)
i += 1
flush()
return segs
def _repair_segments(segs: list[tuple[bool, str]], notes: list[str]) -> str:
"""Structural fixes that only apply outside strings."""
fixed: list[str] = []
for idx, (is_str, seg) in enumerate(segs):
if is_str:
fixed.append(seg)
continue
s = seg
# Python / JS literals -> JSON
s2 = re.sub(r"\bTrue\b", "true", s)
s2 = re.sub(r"\bFalse\b", "false", s2)
s2 = re.sub(r"\bNone\b|\bundefined\b", "null", s2)
if s2 != s:
notes.append("converted Python/JS literals (True/False/None)")
s = s2
# Unquoted keys: { key: or , key: -> "key":
s2 = re.sub(r"([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*:)", r'\1"\2"\3', s)
if s2 != s:
notes.append("quoted bare object keys")
s = s2
# Missing comma before the next string on a new line, e.g.
# "args": ["x"]\n"env": {...} or {...}\n"name2": {...}
# A string directly after a value-ish ending across a newline can never
# be valid JSON without a comma, so inserting one is always safe.
if idx + 1 < len(segs) and segs[idx + 1][0]:
body = s.rstrip()
tail_ws = s[len(body) :]
prev = body[-1:] if body else (fixed[-1].rstrip()[-1:] if fixed else "")
value_ending = prev in ('"', "}", "]") or prev.isdigit()
if "\n" in tail_ws and value_ending:
notes.append("inserted missing commas")
s = body + "," + tail_ws
fixed.append(s)
out = "".join(fixed)
# Trailing commas (safe now: strings are intact, commas here are structural)
segs2 = _tokenize(out, [])
parts: list[str] = []
changed = False
for is_str, seg in segs2:
if is_str:
parts.append(seg)
else:
new = re.sub(r",(\s*[}\]])", r"\1", seg)
changed = changed or new != seg
parts.append(new)
if changed:
notes.append("removed trailing commas")
return "".join(parts)
def _extract_and_balance(text: str, notes: list[str]) -> str:
"""Slice out the JSON object (dropping surrounding prose) and close any
unclosed braces/brackets."""
start = text.find("{")
if start == -1:
return text
if text[:start].strip():
notes.append("ignored text before the JSON block")
stack: list[str] = []
i, n = start, len(text)
end = -1
while i < n:
ch = text[i]
if ch == '"': # skip strings
i += 1
while i < n:
if text[i] == "\\":
i += 2
continue
if text[i] == '"':
break
i += 1
elif ch in "{[":
stack.append("}" if ch == "{" else "]")
elif ch in "}]":
if stack and stack[-1] == ch:
stack.pop()
if not stack:
end = i + 1
break
i += 1
if end != -1:
if text[end:].strip():
notes.append("ignored text after the JSON block")
return text[start:end]
# Ran out of input with open scopes: close them.
if stack:
notes.append("closed unclosed braces/brackets")
return text[start:] + "".join(reversed(stack))
return text[start:]
def repair_json_text(text: str) -> tuple[str, list[str]]:
"""
Best-effort repair of an almost-JSON snippet. Returns (candidate, notes)
where notes is a human-readable list of the fixes applied (deduplicated,
in order). Does NOT guarantee the result parses — callers still try
json.loads and surface its error if repair wasn't enough.
"""
notes: list[str] = []
t = _strip_fences(text, notes)
t = _normalize_unicode(t, notes)
t = t.strip()
# Brace-less inner fragment: "name": { ... } (with no outer braces).
# A quoted key at the start is a strong signal; a bare word only counts
# when followed by '{' so prose like "Note: ..." isn't swallowed.
if re.match(r'^\s*"[^"\n]+"\s*:\s*[{["]', t) or re.match(r"^\s*[A-Za-z_$][\w$.-]*\s*:\s*\{", t):
notes.append("wrapped fragment in braces")
t = "{" + t + "}"
t = _extract_and_balance(t, notes)
segs = _tokenize(t, notes)
t = _repair_segments(segs, notes)
# Re-balance in case comment/quote fixes exposed structure.
t = _extract_and_balance(t, [])
seen: set[str] = set()
unique = [x for x in notes if not (x in seen or seen.add(x))]
return t, unique
def parse_pasted_json_verbose(text: str) -> tuple[dict[str, dict], list[str]]:
"""
Like parse_pasted_json, but forgiving. Tries strict JSON first; if that
fails, runs repair_json_text() and retries. Returns (servers, repair_notes).
repair_notes is empty when the input was already valid.
"""
raw = (text or "").strip()
if not raw:
raise ValueError("Nothing to parse.")
obj = json.loads(text)
try:
return _shape_servers(json.loads(raw)), []
except json.JSONDecodeError as strict_err:
candidate, notes = repair_json_text(raw)
try:
obj = json.loads(candidate)
except json.JSONDecodeError:
# Repair wasn't enough — report the original, more meaningful error.
raise ValueError(
f"Couldn't parse that as JSON even after auto-repair "
f"(line {strict_err.lineno}, column {strict_err.colno}: {strict_err.msg})."
) from strict_err
return _shape_servers(obj), notes
def _shape_servers(obj) -> dict[str, dict]:
"""Shared shape-detection for parsed paste content."""
if not isinstance(obj, dict):
raise ValueError("Top-level JSON must be an object ({ ... }).")
@@ -252,13 +529,30 @@ def parse_pasted_json(text: str) -> dict[str, dict]:
for name, data in servers.items():
if not looks_like_server(data):
raise ValueError(
f"'{name}' doesn't look like an MCP server "
f"(it needs a 'command' or a 'url')."
f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')."
)
clean[str(name)] = data
return clean
def parse_pasted_json(text: str) -> dict[str, dict]:
"""
Accept any of the shapes MCP docs hand out and return {name: server_dict}:
1. Full config: {"mcpServers": {"name": {...}}}
2. Inner block only: {"name": {"command": ...}}
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested)
Input doesn't have to be valid JSON — markdown fences, comments, trailing
commas, smart/single quotes, unquoted keys, surrounding prose, and missing
braces are repaired automatically (see repair_json_text).
Raises ValueError with a human message on anything unrecognizable.
"""
servers, _notes = parse_pasted_json_verbose(text)
return servers
# --------------------------------------------------------------------------- #
# Validation
# --------------------------------------------------------------------------- #
@@ -308,26 +602,30 @@ def system_path() -> str:
dirs: list[str] = []
if sys.platform == "darwin":
for p in ["/etc/paths"] + sorted(glob.glob("/etc/paths.d/*")):
try:
for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]:
with contextlib.suppress(OSError):
dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip())
except OSError:
pass
# Package-manager install prefixes: present on disk once installed,
# accessible to all processes regardless of shell configuration.
for d in (
"/opt/homebrew/bin", "/opt/homebrew/sbin", # Homebrew (Apple Silicon)
"/usr/local/bin", "/usr/local/sbin", # Homebrew (Intel) / manual installs
"/opt/local/bin", "/opt/local/sbin", # MacPorts
"/opt/homebrew/bin",
"/opt/homebrew/sbin", # Homebrew (Apple Silicon)
"/usr/local/bin",
"/usr/local/sbin", # Homebrew (Intel) / manual installs
"/opt/local/bin",
"/opt/local/sbin", # MacPorts
):
dirs.append(d)
elif os.name == "nt":
try:
import winreg
for hive, sub in [
(winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
),
(winreg.HKEY_CURRENT_USER, r"Environment"),
]:
try:
@@ -338,19 +636,23 @@ def system_path() -> str:
pass
except ImportError:
pass
dirs.extend(["C:\\Windows\\System32", "C:\\Windows",
"C:\\Windows\\System32\\Wbem",
os.path.expandvars(r"%ProgramFiles%\nodejs")])
dirs.extend(
[
"C:\\Windows\\System32",
"C:\\Windows",
"C:\\Windows\\System32\\Wbem",
os.path.expandvars(r"%ProgramFiles%\nodejs"),
]
)
else: # Linux / other
try:
for line in Path("/etc/environment").read_text().splitlines():
if line.upper().startswith("PATH="):
dirs.extend(line[5:].strip('"\'').split(os.pathsep))
dirs.extend(line[5:].strip("\"'").split(os.pathsep))
except OSError:
pass
for d in ("/usr/local/bin", "/usr/bin", "/bin",
"/usr/sbin", "/sbin", "/snap/bin"):
for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"):
dirs.append(d)
seen, out = set(), []
@@ -384,7 +686,9 @@ def augmented_path() -> str:
str(home / ".volta" / "bin"),
]
user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin"))
user_dirs += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin"))
user_dirs += glob.glob(
str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin")
)
if os.name == "nt":
appdata = os.environ.get("APPDATA", "")
if appdata:
@@ -407,12 +711,12 @@ def refresh_path_cache():
# Tailored install advice keyed by the runner's basename.
RUNNER_HINTS = {
"npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. "
"With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.",
"With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.",
"node": "Node.js not found. Install from nodejs.org or `brew install node`.",
"uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
"(or `brew install uv`). uvx ships inside uv.",
"(or `brew install uv`). uvx ships inside uv.",
"uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
"(or `brew install uv`).",
"(or `brew install uv`).",
"python": "Python not found on this PATH.",
"python3": "Python 3 not found on this PATH.",
"docker": "Docker not found. Install Docker Desktop and make sure it's running.",
@@ -428,8 +732,10 @@ def runner_hint(cmd: str) -> str:
if base in RUNNER_HINTS:
return RUNNER_HINTS[base]
if ("/" in cmd) or ("\\" in cmd):
return (f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. "
f"Check the path and that it's marked executable (chmod +x).")
return (
f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. "
f"Check the path and that it's marked executable (chmod +x)."
)
return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'."
@@ -466,14 +772,29 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail}
"""
if "url" in data and "command" not in data:
return {"status": "remote", "label": "remote endpoint", "detail": data.get("url", ""),
"resolved": {}, "in_base": {}, "searched_path": "", "hints": [], "commands": []}
return {
"status": "remote",
"label": "remote endpoint",
"detail": data.get("url", ""),
"resolved": {},
"in_base": {},
"searched_path": "",
"hints": [],
"commands": [],
}
cmds = _commands_to_check(data)
if not cmds:
return {"status": "unknown", "label": "no command set", "detail": "",
"resolved": {}, "in_base": {}, "searched_path": "",
"hints": ["This server has no 'command' to run."], "commands": []}
return {
"status": "unknown",
"label": "no command set",
"detail": "",
"resolved": {},
"in_base": {},
"searched_path": "",
"hints": ["This server has no 'command' to run."],
"commands": [],
}
aug = path or augmented_path()
resolved: dict[str, str | None] = {}
@@ -515,8 +836,16 @@ def check_dependency(data: dict, path: str | None = None) -> dict:
status = "ok"
label = "found: " + ", ".join(Path(p).name for p in resolved.values())
return {"status": status, "label": label, "resolved": resolved, "in_base": in_base,
"searched_path": aug, "hints": hints, "commands": cmds, "detail": ""}
return {
"status": status,
"label": label,
"resolved": resolved,
"in_base": in_base,
"searched_path": aug,
"hints": hints,
"commands": cmds,
"detail": "",
}
def diagnostics_text(name: str, data: dict) -> str:
@@ -563,8 +892,10 @@ def diagnostics_text(name: str, data: dict) -> str:
L.append((" + " if d in extra else " ") + d)
if extra:
L.append("")
L.append("(+ = user-specific dirs not in the standard system PATH. "
"Claude Desktop launched from Finder may NOT have these.)")
L.append(
"(+ = user-specific dirs not in the standard system PATH. "
"Claude Desktop launched from Finder may NOT have these.)"
)
return "\n".join(L)
@@ -582,9 +913,12 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
if not (url.startswith("http://") or url.startswith("https://")):
return False, "not an http(s) URL"
req = urllib.request.Request(
url, method="GET",
headers={"Accept": "text/event-stream, application/json, */*",
"User-Agent": "BetterClaudeConfig/1.0"},
url,
method="GET",
headers={
"Accept": "text/event-stream, application/json, */*",
"User-Agent": "BetterClaudeConfig/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
@@ -596,9 +930,9 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
if isinstance(reason, socket.timeout):
return False, "timed out"
return False, str(reason)
except socket.timeout:
except TimeoutError:
return False, "timed out"
except Exception as e: # noqa: BLE001 - surface anything else as a clean message
except Exception as e:
return False, str(e)