Merge branch 'main' into feat/75-theming
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled

Union conflict at the end of tests/test_core.py -- both branches appended a
test block. Kept both, main's #72/#73 block first. Verified: 265 test
functions = 238 baseline + 15 (#77) + 12 (theming), no duplicates.
This commit is contained in:
2026-07-20 12:50:09 -04:00
3 changed files with 283 additions and 10 deletions
+117 -6
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import base64
import contextlib
import copy
import difflib
import functools
import glob
@@ -49,6 +50,12 @@ DISABLED_KEY = "_disabledMcpServers"
# parks the rest under DISABLED_KEY.
SETS_KEY = "_bccServerSets"
# Top-level keys BCC itself authors. They live in the client's config file, but
# BCC is their owner, so on a stale-file merge the in-memory copy wins over the
# on-disk one (see `carry_owned_keys`). Any future BCC-authored key belongs
# here -- forgetting to add one is exactly how #73 happened.
BCC_OWNED_KEYS = (SETS_KEY,)
BACKUP_DIRNAME = ".bcc_backups"
MAX_BACKUPS = 15
@@ -178,16 +185,63 @@ class Profile:
self.path = Path(self.path)
class _NoRaw:
"""Sentinel for ServerEntry.raw.
`None` can't do this job: `{"mcpServers": {"foo": null}}` is legal JSON and
a real malformed-entry case, so None has to mean "the config said null",
not "there was nothing here".
"""
__slots__ = ()
def __repr__(self) -> str: # keeps ServerEntry reprs readable in test output
return "<no raw>"
NO_RAW = _NoRaw()
@dataclass
class ServerEntry:
"""One server definition.
`data` is always a dict so every consumer can treat it as one. When the
config held something that wasn't a JSON object for this server (a string,
a number, a list -- all legal JSON, all wrong here), `data` is empty and
the original value is preserved verbatim in `raw` so Save round-trips it
instead of silently deleting the user's line. `lint_servers` surfaces it.
`raw` defaults to the NO_RAW sentinel rather than None, because a config
value of literal `null` is itself a malformed entry worth preserving.
Assigning `data` means the user replaced the definition through the editor,
which retires `raw` -- use `set_data` so that can't be forgotten.
"""
name: str
data: dict
enabled: bool = True
raw: object = NO_RAW
@property
def kind(self) -> str:
return "remote" if "url" in self.data and "command" not in self.data else "stdio"
@property
def malformed(self) -> bool:
"""True when the config value for this server wasn't a JSON object."""
return self.raw is not NO_RAW
def set_data(self, data: dict) -> None:
"""Replace the definition from the editor, clearing any malformed original."""
self.data = data
self.raw = NO_RAW
def config_value(self):
"""What to write back to the config: the edited dict, or the untouched
malformed original when the user never edited it."""
return self.data if self.raw is NO_RAW else self.raw
# --------------------------------------------------------------------------- #
# Theming (issue #75)
@@ -584,13 +638,30 @@ def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]:
return obj, notes, pretty
def _server_entry(name: str, data, enabled: bool) -> ServerEntry:
"""Build a ServerEntry, tolerating a value that isn't a JSON object.
A hand-edited config can legally hold `{"mcpServers": {"foo": "oops"}}` --
valid JSON, wrong shape. Calling dict() on that raises, which used to take
the whole load down before the linter ever got a look at it (#72). Keep the
original instead and let the linter report it.
"""
if isinstance(data, dict):
return ServerEntry(name=name, data=dict(data), enabled=enabled)
return ServerEntry(name=name, data={}, enabled=enabled, raw=data)
def extract_servers(cfg: dict) -> list[ServerEntry]:
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers."""
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.
Never raises on a structurally-odd config -- malformed entries come back as
empty-data entries carrying their original value (see `_server_entry`).
"""
out: list[ServerEntry] = []
for name, data in (cfg.get("mcpServers") or {}).items():
out.append(ServerEntry(name=name, data=dict(data), enabled=True))
out.append(_server_entry(name, data, True))
for name, data in (cfg.get(DISABLED_KEY) or {}).items():
out.append(ServerEntry(name=name, data=dict(data), enabled=False))
out.append(_server_entry(name, data, False))
return out
@@ -682,8 +753,8 @@ def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict:
Write the server list back into `cfg` in place, preserving every other key
and the position of `mcpServers`. Returns the same dict for convenience.
"""
enabled = {s.name: s.data for s in servers if s.enabled}
disabled = {s.name: s.data for s in servers if not s.enabled}
enabled = {s.name: s.config_value() for s in servers if s.enabled}
disabled = {s.name: s.config_value() for s in servers if not s.enabled}
cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise
if disabled:
@@ -696,6 +767,34 @@ def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict:
# --------------------------------------------------------------------------- #
# Write (atomic, with rotating backups)
# --------------------------------------------------------------------------- #
def carry_owned_keys(local_cfg: dict, fresh_cfg: dict) -> list[str]:
"""Carry BCC-authored top-level keys from `local_cfg` onto `fresh_cfg`.
Used by the stale-file "Merge & save" path, which reloads the file from
disk and re-applies the user's server edits. That reload used to drop
anything BCC owns but `apply_servers` doesn't write -- named server sets
vanished without a word (#73). BCC owns these keys, so the in-memory copy
wins; mutates `fresh_cfg` in place.
Returns the keys where the on-disk copy differed and was overwritten, so
the caller can tell the user something was actually contested rather than
merely carried across.
Deliberately one-directional: a key absent locally is left alone on disk.
We can't tell "user deleted their last set" from "user never had sets and
another machine just added some", and silently deleting someone else's
data is the worse of the two failures.
"""
conflicts: list[str] = []
for key in BCC_OWNED_KEYS:
if key not in local_cfg:
continue
if key in fresh_cfg and fresh_cfg[key] != local_cfg[key]:
conflicts.append(key)
fresh_cfg[key] = copy.deepcopy(local_cfg[key])
return conflicts
def _make_backup(path: Path) -> Path:
bdir = path.parent / BACKUP_DIRNAME
bdir.mkdir(exist_ok=True)
@@ -1512,9 +1611,21 @@ def lint_server(name: str, data: dict) -> list[str]:
def lint_servers(servers: list[ServerEntry]) -> list[str]:
"""Concatenate lint_server warnings across every entry, in order."""
"""Concatenate lint_server warnings across every entry, in order.
Entries whose config value wasn't a JSON object at all are reported here
rather than in lint_server, which takes an already-dict `data` (#72).
"""
out: list[str] = []
for s in servers:
if s.malformed:
nm = s.name.strip() or "(unnamed)"
out.append(
f"'{nm}': server definition is not an object "
f"(found {type(s.raw).__name__}) -- it is preserved as-is; "
f"edit it to replace it with a proper definition"
)
continue
out.extend(lint_server(s.name, s.data))
return out