From ac2e73e9d75da40363ad1f2e341755c56e7c6684 Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Sun, 12 Jul 2026 13:55:36 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20named=20server=20sets=20=E2=80=94=20sav?= =?UTF-8?q?e/apply=20the=20Active/Disabled=20split=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets live in the config under _bccServerSets (bcc-owned, ignored by Claude, travels with the file). Apply enables exactly the set's members and parks the rest; vanished members are reported, not fatal. GUI row: set combo + Apply + Save set… + delete. Closes #52 Co-Authored-By: Claude Fable 5 --- bcc.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++ bcc_core.py | 70 ++++++++++++++++++++++++++++++++++ tests/test_core.py | 62 +++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) diff --git a/bcc.py b/bcc.py index caebee8..5110ff6 100644 --- a/bcc.py +++ b/bcc.py @@ -38,6 +38,7 @@ from PySide6.QtWidgets import ( QGridLayout, QHBoxLayout, QHeaderView, + QInputDialog, QLabel, QLineEdit, QListWidget, @@ -1692,6 +1693,30 @@ class MainWindow(QMainWindow): self.search_box.textChanged.connect(self._on_search_changed) v.addWidget(self.search_box) + # Named server sets (issue #52): apply a saved Active/Disabled split + # in one click. Sets live in the config file under _bccServerSets. + sets_row = QHBoxLayout() + sets_lbl = QLabel("Set") + sets_lbl.setObjectName("muted") + sets_row.addWidget(sets_lbl) + self.sets_combo = QComboBox() + self.sets_combo.setMinimumWidth(120) + sets_row.addWidget(self.sets_combo, 1) + self.apply_set_btn = QPushButton("Apply") + self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest") + self.apply_set_btn.clicked.connect(self._apply_selected_set) + sets_row.addWidget(self.apply_set_btn) + self.save_set_btn = QPushButton("Save set…") + self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set") + self.save_set_btn.clicked.connect(self._save_set) + sets_row.addWidget(self.save_set_btn) + self.del_set_btn = QPushButton("−") + self.del_set_btn.setToolTip("Delete the selected set") + self.del_set_btn.setMaximumWidth(32) + self.del_set_btn.clicked.connect(self._delete_set) + sets_row.addWidget(self.del_set_btn) + v.addLayout(sets_row) + # Active and Disabled sections live in a vertical splitter so the user # can drag the divider instead of being stuck with a fixed-height # disabled list. @@ -1892,6 +1917,7 @@ class MainWindow(QMainWindow): self._undo_stack.clear() self.undo_btn.setEnabled(False) self._health.clear() # health results are per-profile; a fresh load invalidates them + self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file self._refresh_tables(select_index=0 if self.servers else -1) self._update_status(saved=False) if repaired: @@ -1991,6 +2017,73 @@ class MainWindow(QMainWindow): cur = self._current_index() self._refresh_tables(select_index=cur if cur >= 0 else None) + # --- named server sets (issue #52) ------------------------------------ # + def _refresh_sets_combo(self, select: str | None = None): + sets = core.list_server_sets(self.full_config) + self.sets_combo.blockSignals(True) + self.sets_combo.clear() + for name in sorted(sets): + self.sets_combo.addItem(name) + if select is not None: + idx = self.sets_combo.findText(select) + if idx >= 0: + self.sets_combo.setCurrentIndex(idx) + self.sets_combo.blockSignals(False) + has_sets = bool(sets) + self.apply_set_btn.setEnabled(has_sets) + self.del_set_btn.setEnabled(has_sets) + + def _apply_selected_set(self): + name = self.sets_combo.currentText() + sets = core.list_server_sets(self.full_config) + if name not in sets: + return + self._push_undo() + missing = core.apply_server_set(self.servers, sets[name]) + self._refresh_tables(select_index=self._current_index() if self.servers else None) + self._mark_dirty() + on = sum(1 for s in self.servers if s.enabled) + msg = f"Applied set “{name}” · {on} enabled. Review and Save." + if missing: + msg += f" ⚠ no longer in this config: {', '.join(missing)}" + self.status.setText(msg) + + def _save_set(self): + name, ok = QInputDialog.getText( + self, + "Save server set", + "Set name (saves which servers are currently Active):", + text=self.sets_combo.currentText(), + ) + name = name.strip() + if not ok or not name: + return + if name in core.list_server_sets(self.full_config) and ( + QMessageBox.question(self, "Set exists", f"Replace set “{name}”?") + != QMessageBox.StandardButton.Yes + ): + return + members = core.save_server_set(self.full_config, name, self.servers) + self._refresh_sets_combo(select=name) + self._mark_dirty() # the set is written on the next Save + self.status.setText( + f"Set “{name}” saved ({len(members)} server(s)). Press Save to write it." + ) + + def _delete_set(self): + name = self.sets_combo.currentText() + if not name: + return + if ( + QMessageBox.question(self, "Delete set", f"Delete set “{name}”?") + != QMessageBox.StandardButton.Yes + ): + return + if core.delete_server_set(self.full_config, name): + self._refresh_sets_combo() + self._mark_dirty() + self.status.setText(f"Set “{name}” deleted. Press Save to write the change.") + # --- test all (spawn-test every enabled local server) ---------------- # def _test_all_servers(self): targets = [s for s in self.servers if s.enabled and s.kind == "stdio"] diff --git a/bcc_core.py b/bcc_core.py index be511cf..a5298d0 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -39,6 +39,12 @@ CONFIG_FILENAME = "claude_desktop_config.json" # we can toggle it back on without losing the definition. DISABLED_KEY = "_disabledMcpServers" +# Named server sets: {set_name: [enabled server names]}. Same pattern as +# DISABLED_KEY — a bcc-owned key Claude ignores, stored in the config file so +# sets travel with it. Applying a set enables exactly the listed servers and +# parks the rest under DISABLED_KEY. +SETS_KEY = "_bccServerSets" + BACKUP_DIRNAME = ".bcc_backups" MAX_BACKUPS = 15 @@ -409,6 +415,70 @@ def extract_servers(cfg: dict) -> list[ServerEntry]: return out +# --------------------------------------------------------------------------- # +# Named server sets (issue #52) +# --------------------------------------------------------------------------- # +def list_server_sets(cfg: dict) -> dict[str, list[str]]: + """ + Return {set_name: [enabled server names]} from cfg's SETS_KEY. + + Fail-soft: entries whose value isn't a list of strings (hand-edited or + corrupted) are skipped rather than raising, so one bad set never hides + the rest. + """ + raw = cfg.get(SETS_KEY) + if not isinstance(raw, dict): + return {} + out: dict[str, list[str]] = {} + for name, members in raw.items(): + if isinstance(members, list) and all(isinstance(m, str) for m in members): + out[str(name)] = list(members) + return out + + +def save_server_set(cfg: dict, name: str, servers: list[ServerEntry]) -> list[str]: + """ + Snapshot the current enabled-server names into cfg under SETS_KEY as + `name` (overwriting an existing set of that name). Returns the saved + member list. The caller decides when cfg reaches disk (normal Save flow). + """ + members = [s.name for s in servers if s.enabled] + sets = cfg.get(SETS_KEY) + if not isinstance(sets, dict): + sets = {} + cfg[SETS_KEY] = sets + sets[name] = members + return members + + +def delete_server_set(cfg: dict, name: str) -> bool: + """Remove set `name` from cfg. Drops SETS_KEY entirely when the last set + goes, so untouched configs don't grow an empty bcc key. Returns True if + something was deleted.""" + sets = cfg.get(SETS_KEY) + if not isinstance(sets, dict) or name not in sets: + return False + del sets[name] + if not sets: + cfg.pop(SETS_KEY, None) + return True + + +def apply_server_set(servers: list[ServerEntry], enabled_names: list[str]) -> list[str]: + """ + Enable exactly the servers named in `enabled_names`; disable every other + entry (in place). Returns the set members that no longer exist in + `servers` — the caller surfaces those as a warning, and the rest of the + set still applies. + """ + wanted = set(enabled_names) + present: set[str] = set() + for s in servers: + s.enabled = s.name in wanted + present.add(s.name) + return sorted(wanted - present) + + def resolve_name_collision(name: str, existing: set[str]) -> str: """ Return a name guaranteed not to collide with `existing`. diff --git a/tests/test_core.py b/tests/test_core.py index 67f17fe..5afaf2e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -769,6 +769,68 @@ def test_args_secret_warning_empty(): assert c.args_secret_warning({"args": []}) is None +# --------------------------------------------------------------------------- # +# Named server sets (issue #52) +# --------------------------------------------------------------------------- # +def _three_servers(): + return [ + c.ServerEntry("alpha", {"command": "npx"}, True), + c.ServerEntry("beta", {"command": "uvx"}, True), + c.ServerEntry("gamma", {"url": "https://x.example/mcp"}, False), + ] + + +def test_save_and_list_server_sets_roundtrip(): + cfg: dict = {} + members = c.save_server_set(cfg, "webdev", _three_servers()) + assert members == ["alpha", "beta"] # only the enabled ones + assert c.list_server_sets(cfg) == {"webdev": ["alpha", "beta"]} + + +def test_apply_server_set_enables_exactly_the_members(): + servers = _three_servers() + missing = c.apply_server_set(servers, ["gamma"]) + assert missing == [] + assert [s.enabled for s in servers] == [False, False, True] + + +def test_apply_server_set_reports_missing_members(): + servers = _three_servers() + missing = c.apply_server_set(servers, ["alpha", "vanished", "gone"]) + assert missing == ["gone", "vanished"] + assert [s.enabled for s in servers] == [True, False, False] # rest still applied + + +def test_delete_server_set_drops_empty_key(): + cfg: dict = {} + c.save_server_set(cfg, "only", _three_servers()) + assert c.delete_server_set(cfg, "only") is True + assert c.SETS_KEY not in cfg # no empty bcc key left behind + assert c.delete_server_set(cfg, "only") is False + + +def test_server_sets_survive_write_and_reload(tmp_path): + cfgpath = tmp_path / "cfg.json" + cfg = {"mcpServers": {"alpha": {"command": "npx"}}} + c.save_server_set(cfg, "webdev", [c.ServerEntry("alpha", {"command": "npx"}, True)]) + c.write_config(cfgpath, cfg) + reloaded = c.load_config(cfgpath) + assert c.list_server_sets(reloaded) == {"webdev": ["alpha"]} + + +def test_apply_servers_preserves_sets_key(): + cfg: dict = {"mcpServers": {}} + c.save_server_set(cfg, "s", [c.ServerEntry("alpha", {"command": "npx"}, True)]) + c.apply_servers(cfg, _three_servers()) + assert c.SETS_KEY in cfg # the server writer never touches sets + + +def test_list_server_sets_skips_malformed_entries(): + cfg = {c.SETS_KEY: {"good": ["a"], "bad-not-list": "a", "bad-items": ["a", 3]}} + assert c.list_server_sets(cfg) == {"good": ["a"]} + assert c.list_server_sets({c.SETS_KEY: "junk"}) == {} + + # --------------------------------------------------------------------------- # # resolve_name_collision (paste/import duplicate-name handling — issue #8) # --------------------------------------------------------------------------- # -- 2.52.0