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
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:
@@ -2815,3 +2815,179 @@ def test_claude_code_profile_warns_only_about_unset_variables():
|
||||
|
||||
def test_no_references_means_no_warnings():
|
||||
assert c.env_ref_warnings({"command": "npx", "args": ["-y", "pkg"]}, None) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Client adapters (issue #5 — cross-client support, phase 1)
|
||||
#
|
||||
# The refactor's promise is twofold: (1) the two Claude clients behave exactly
|
||||
# as before, and (2) the ClientSpec seam is real — a client with a different
|
||||
# servers key and a different per-server shape flows through the same pipeline.
|
||||
# A synthetic "VS Code-like" spec stands in for the phase-2 client so the
|
||||
# abstraction is proven now, before anything depends on it.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_claude_specs_are_registered_and_mcpservers_shaped():
|
||||
assert c.CLAUDE_DESKTOP.servers_key == "mcpServers"
|
||||
assert c.CLAUDE_CODE.servers_key == "mcpServers"
|
||||
assert c.CLAUDE_DESKTOP.disabled_key == c.DISABLED_KEY
|
||||
assert c.CLAUDE_CODE.disabled_key == c.DISABLED_KEY
|
||||
# capabilities the old inline filename checks used to compute
|
||||
assert c.CLAUDE_DESKTOP.expands_env_refs is False
|
||||
assert c.CLAUDE_CODE.expands_env_refs is True
|
||||
assert c.CLAUDE_DESKTOP.supports_restart is True
|
||||
assert c.CLAUDE_CODE.supports_restart is False
|
||||
assert set(c.CLIENT_SPECS) == {c.CLAUDE_DESKTOP, c.CLAUDE_CODE}
|
||||
assert c.DEFAULT_CLIENT is c.CLAUDE_DESKTOP
|
||||
|
||||
|
||||
def test_client_by_key_round_trips_and_misses():
|
||||
assert c.client_by_key("claude_desktop") is c.CLAUDE_DESKTOP
|
||||
assert c.client_by_key("claude_code") is c.CLAUDE_CODE
|
||||
assert c.client_by_key("nope") is None
|
||||
|
||||
|
||||
def test_resolve_client_matches_the_old_filename_rule():
|
||||
assert c.resolve_client("/x/Claude/claude_desktop_config.json") is c.CLAUDE_DESKTOP
|
||||
assert c.resolve_client(Path.home() / ".claude.json") is c.CLAUDE_CODE
|
||||
assert c.resolve_client("/repo/.mcp.json") is c.CLAUDE_CODE
|
||||
assert c.resolve_client(Path.home() / ".claude" / "settings.json") is c.CLAUDE_CODE
|
||||
|
||||
|
||||
def test_profile_auto_resolves_client_from_path():
|
||||
desktop = c.Profile(
|
||||
label="Claude", path="/x/Claude/claude_desktop_config.json", config_exists=True
|
||||
)
|
||||
code = c.Profile(label="Claude Code", path="/home/me/.claude.json", config_exists=True)
|
||||
assert desktop.client is c.CLAUDE_DESKTOP
|
||||
assert code.client is c.CLAUDE_CODE
|
||||
|
||||
|
||||
def test_profile_honours_an_explicit_client():
|
||||
# An explicit spec is not overridden by the path-based resolver.
|
||||
p = c.Profile(
|
||||
label="odd",
|
||||
path="/somewhere/claude_desktop_config.json",
|
||||
config_exists=True,
|
||||
client=c.CLAUDE_CODE,
|
||||
)
|
||||
assert p.client is c.CLAUDE_CODE
|
||||
|
||||
|
||||
def test_desktop_gating_and_env_expansion_read_off_the_spec():
|
||||
desktop = c.Profile(label="d", path="/x/Claude/claude_desktop_config.json", config_exists=True)
|
||||
code = c.Profile(label="c", path=Path.home() / ".claude.json", config_exists=True)
|
||||
assert c.profile_targets_claude_desktop(desktop) is True
|
||||
assert c.profile_targets_claude_desktop(code) is False
|
||||
assert c.client_expands_env_refs(desktop) is False
|
||||
assert c.client_expands_env_refs(code) is True
|
||||
|
||||
|
||||
def test_extract_and_apply_default_spec_is_unchanged():
|
||||
# No spec argument must behave byte-for-byte like the pre-refactor code.
|
||||
cfg = {"mcpServers": {"a": {"command": "x"}}, "_disabledMcpServers": {"b": {"command": "y"}}}
|
||||
servers = c.extract_servers(cfg)
|
||||
assert {(s.name, s.enabled) for s in servers} == {("a", True), ("b", False)}
|
||||
out = c.apply_servers({}, servers)
|
||||
assert out == {
|
||||
"mcpServers": {"a": {"command": "x"}},
|
||||
"_disabledMcpServers": {"b": {"command": "y"}},
|
||||
}
|
||||
|
||||
|
||||
# A stand-in for the phase-2 VS Code adapter: different top-level key
|
||||
# ("servers"), a different disabled key, and a per-server shape that carries a
|
||||
# `type` field the internal model doesn't. entry_to/from_internal are the only
|
||||
# things it overrides — proving that's the whole extension point.
|
||||
class _FakeVSCode(c.ClientSpec):
|
||||
def entry_to_internal(self, value):
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
return {k: v for k, v in value.items() if k != "type"}
|
||||
|
||||
def entry_from_internal(self, data):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
return {"type": "stdio", **data}
|
||||
|
||||
|
||||
_VSCODE = _FakeVSCode(
|
||||
key="vscode_fake",
|
||||
label="VS Code (test)",
|
||||
servers_key="servers",
|
||||
disabled_key="_bccDisabledServers",
|
||||
)
|
||||
|
||||
|
||||
def test_extract_reads_a_custom_servers_key_and_translates_shape():
|
||||
cfg = {"servers": {"a": {"type": "stdio", "command": "x", "args": ["-y"]}}}
|
||||
servers = c.extract_servers(cfg, _VSCODE)
|
||||
assert len(servers) == 1
|
||||
# the `type` field was translated out of the internal model
|
||||
assert servers[0].data == {"command": "x", "args": ["-y"]}
|
||||
|
||||
|
||||
def test_apply_writes_a_custom_key_translates_back_and_keeps_other_keys():
|
||||
original = {"servers": {"old": {"type": "stdio", "command": "z"}}, "keepMe": {"x": 1}}
|
||||
servers = c.extract_servers(original, _VSCODE)
|
||||
out = c.apply_servers(original, servers, _VSCODE)
|
||||
# round-trips through the custom key with the shape restored
|
||||
assert out["servers"] == {"old": {"type": "stdio", "command": "z"}}
|
||||
# the cardinal rule generalises: mcpServers is never introduced, and every
|
||||
# unrelated key survives verbatim
|
||||
assert "mcpServers" not in out
|
||||
assert out["keepMe"] == {"x": 1}
|
||||
|
||||
|
||||
def test_apply_uses_the_custom_disabled_key():
|
||||
servers = [
|
||||
c.ServerEntry("on", {"command": "a"}, True),
|
||||
c.ServerEntry("off", {"command": "b"}, False),
|
||||
]
|
||||
out = c.apply_servers({}, servers, _VSCODE)
|
||||
assert out["servers"] == {"on": {"type": "stdio", "command": "a"}}
|
||||
assert out["_bccDisabledServers"] == {"off": {"type": "stdio", "command": "b"}}
|
||||
assert c.DISABLED_KEY not in out
|
||||
|
||||
|
||||
def test_spec_with_no_disabled_key_drops_disabled_and_never_parks():
|
||||
no_park = c.ClientSpec(
|
||||
key="nopark", label="No Park", servers_key="mcpServers", disabled_key=None
|
||||
)
|
||||
servers = [
|
||||
c.ServerEntry("on", {"command": "a"}, True),
|
||||
c.ServerEntry("off", {"command": "b"}, False),
|
||||
]
|
||||
out = c.apply_servers({}, servers, no_park)
|
||||
assert out == {"mcpServers": {"on": {"command": "a"}}}
|
||||
assert c.DISABLED_KEY not in out
|
||||
assert no_park.section_keys() == ("mcpServers",)
|
||||
|
||||
|
||||
def test_section_keys_reports_both_when_a_disabled_key_exists():
|
||||
assert c.CLAUDE_DESKTOP.section_keys() == ("mcpServers", c.DISABLED_KEY)
|
||||
assert _VSCODE.section_keys() == ("servers", "_bccDisabledServers")
|
||||
|
||||
|
||||
def test_malformed_entry_round_trips_through_the_default_spec():
|
||||
# #72's non-object server value must still be preserved verbatim on save.
|
||||
cfg = {"mcpServers": {"bad": "oops", "good": {"command": "x"}}}
|
||||
servers = c.extract_servers(cfg)
|
||||
assert any(s.malformed and s.name == "bad" for s in servers)
|
||||
out = c.apply_servers({}, servers)
|
||||
assert out["mcpServers"]["bad"] == "oops"
|
||||
|
||||
|
||||
def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path):
|
||||
loaded = {"servers": {"a": {"type": "stdio", "command": "x", "env": {"API_KEY": "sekret"}}}}
|
||||
sections = c._server_sections(loaded, _VSCODE)
|
||||
assert "servers" in sections
|
||||
assert "mcpServers" not in sections
|
||||
# secret masking still applies through the custom key
|
||||
assert sections["servers"]["a"]["env"]["API_KEY"] == c.MASK
|
||||
|
||||
disk = {"servers": {"a": {"type": "stdio", "command": "CHANGED"}}}
|
||||
p = tmp_path / "vscode.json"
|
||||
p.write_text(json.dumps(disk), encoding="utf-8")
|
||||
changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE)
|
||||
assert "servers" in changed_keys
|
||||
assert diff # a server-section change under the custom key is diffed
|
||||
|
||||
Reference in New Issue
Block a user