feat: named server sets — save/apply the Active/Disabled split (#52)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ from PySide6.QtWidgets import (
|
|||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
@@ -1692,6 +1693,30 @@ class MainWindow(QMainWindow):
|
|||||||
self.search_box.textChanged.connect(self._on_search_changed)
|
self.search_box.textChanged.connect(self._on_search_changed)
|
||||||
v.addWidget(self.search_box)
|
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
|
# Active and Disabled sections live in a vertical splitter so the user
|
||||||
# can drag the divider instead of being stuck with a fixed-height
|
# can drag the divider instead of being stuck with a fixed-height
|
||||||
# disabled list.
|
# disabled list.
|
||||||
@@ -1892,6 +1917,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._undo_stack.clear()
|
self._undo_stack.clear()
|
||||||
self.undo_btn.setEnabled(False)
|
self.undo_btn.setEnabled(False)
|
||||||
self._health.clear() # health results are per-profile; a fresh load invalidates them
|
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._refresh_tables(select_index=0 if self.servers else -1)
|
||||||
self._update_status(saved=False)
|
self._update_status(saved=False)
|
||||||
if repaired:
|
if repaired:
|
||||||
@@ -1991,6 +2017,73 @@ class MainWindow(QMainWindow):
|
|||||||
cur = self._current_index()
|
cur = self._current_index()
|
||||||
self._refresh_tables(select_index=cur if cur >= 0 else None)
|
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) ---------------- #
|
# --- test all (spawn-test every enabled local server) ---------------- #
|
||||||
def _test_all_servers(self):
|
def _test_all_servers(self):
|
||||||
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
|
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
|
||||||
|
|||||||
+70
@@ -39,6 +39,12 @@ CONFIG_FILENAME = "claude_desktop_config.json"
|
|||||||
# we can toggle it back on without losing the definition.
|
# we can toggle it back on without losing the definition.
|
||||||
DISABLED_KEY = "_disabledMcpServers"
|
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"
|
BACKUP_DIRNAME = ".bcc_backups"
|
||||||
MAX_BACKUPS = 15
|
MAX_BACKUPS = 15
|
||||||
|
|
||||||
@@ -409,6 +415,70 @@ def extract_servers(cfg: dict) -> list[ServerEntry]:
|
|||||||
return out
|
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:
|
def resolve_name_collision(name: str, existing: set[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Return a name guaranteed not to collide with `existing`.
|
Return a name guaranteed not to collide with `existing`.
|
||||||
|
|||||||
@@ -769,6 +769,68 @@ def test_args_secret_warning_empty():
|
|||||||
assert c.args_secret_warning({"args": []}) is None
|
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)
|
# resolve_name_collision (paste/import duplicate-name handling — issue #8)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user