refactor: introduce ClientSpec adapter; route Claude Desktop + Code through it (#5)
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 24s
CI / Lint (ruff) (pull_request) Successful in 21s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 32s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 31s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 35s
CI / Catalog signature (pull_request) Successful in 26s

Cross-client support (Cursor / Windsurf / VS Code) was blocked on Claude's
layout being hard-coded throughout the code: servers always under the literal
"mcpServers" key, a fixed set of Claude file locations, and "which client is
this?" answered by sniffing a filename. Adding a client that differs on any of
those axes meant chasing those assumptions through a dozen sites.

This is phase 1 of #5: the keystone refactor, with NO behaviour change. It adds
a ClientSpec adapter that captures the three things that vary across clients --
the top-level servers key, the config discovery paths, and the per-server value
shape -- plus the capability flags that were previously computed inline from a
filename (does the client expand ${VAR}? can we offer Restart?).

- ClientSpec (frozen dataclass): servers_key, disabled_key, config_filename,
  expands_env_refs, supports_restart, and entry_to_internal/entry_from_internal
  -- the per-server translation seam, identity for any mcpServers-shaped client,
  the single point a differently-shaped client (VS Code's type/inputs form)
  overrides.
- CLAUDE_DESKTOP and CLAUDE_CODE specs; both use mcpServers + the existing
  parking key, so their translation is the identity and nothing changes for
  today's users. resolve_client(path) reproduces the old filename rule exactly;
  each Profile now carries its resolved .client.
- extract_servers / apply_servers / _server_sections / external_change_summary
  take an optional spec and default to Claude's layout, so every existing call
  site and test that omits a spec is byte-for-byte unchanged. The cardinal rule
  now generalises: apply_servers only ever writes the client's own two keys,
  parameterised rather than hard-coded.
- profile_targets_claude_desktop and client_expands_env_refs are now thin reads
  off the profile's spec -- one source of truth for client identity instead of
  scattered filename checks -- with identical answers.
- GUI: the load, Copy-to, save and stale-merge paths pass the profile's spec
  into the core calls. Mechanical; no logic moved into bcc.py (which CI can't
  test -- no PySide6).

Tests: +14. Existing suite unchanged and green (behaviour preservation). A
synthetic non-mcpServers spec ("servers" key, a different disabled key, a
per-server `type` field) exercises the whole pipeline -- extract, apply,
masking, external-change diff -- proving the seam actually generalises before
any real client depends on it. 458 passed, 1 skipped; ruff clean.

Refs #5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
This commit is contained in:
2026-08-04 02:53:51 +00:00
co-authored by Claude Opus 4.8
parent f168079755
commit 4a95b370b9
4 changed files with 376 additions and 34 deletions
+6 -6
View File
@@ -2120,7 +2120,7 @@ class MainWindow(QMainWindow):
# but keep it inside the guard: a load failure must leave the previously
# loaded profile intact instead of half-swapping the window's state.
try:
servers = core.extract_servers(self.full_config)
servers = core.extract_servers(self.full_config, profile.client)
except Exception as exc: # pragma: no cover - defence in depth
QMessageBox.critical(
self,
@@ -2590,7 +2590,7 @@ class MainWindow(QMainWindow):
except Exception as e:
QMessageBox.critical(self, "Copy failed", f"Couldn't read {dest.label}:\n{e}")
return
existing = core.extract_servers(dest_cfg)
existing = core.extract_servers(dest_cfg, dest.client)
names = {s.name for s in existing}
if src.name in names:
ans = QMessageBox.question(
@@ -2602,7 +2602,7 @@ class MainWindow(QMainWindow):
return
existing = [s for s in existing if s.name != src.name]
existing.append(core.ServerEntry(src.name, dict(src.data), True))
core.apply_servers(dest_cfg, existing)
core.apply_servers(dest_cfg, existing, dest.client)
try:
backup = core.write_config(dest.path, dest_cfg)
except Exception as e:
@@ -2662,7 +2662,7 @@ class MainWindow(QMainWindow):
and disk_stat != self._loaded_stat
):
changed_keys, server_diff = core.external_change_summary(
self.full_config, self.current_profile.path
self.full_config, self.current_profile.path, self.current_profile.client
)
dlg = StaleDialog(self, str(self.current_profile.path), changed_keys, server_diff)
if not dlg.exec():
@@ -2678,7 +2678,7 @@ class MainWindow(QMainWindow):
# changed in this session (named sets), which apply_servers
# doesn't write. Carry them over before saving (#73).
contested = core.carry_owned_keys(self.full_config, fresh)
core.apply_servers(fresh, self.servers)
core.apply_servers(fresh, self.servers, self.current_profile.client)
try:
backup = core.write_config(self.current_profile.path, fresh)
except Exception as e:
@@ -2703,7 +2703,7 @@ class MainWindow(QMainWindow):
return
# else OVERWRITE: fall through to normal write
core.apply_servers(self.full_config, self.servers)
core.apply_servers(self.full_config, self.servers, self.current_profile.client)
try:
backup = core.write_config(self.current_profile.path, self.full_config)
except Exception as e: