Skip to content

Commit eb70177

Browse files
committed
feat(clients): fall back to ~/.claude.json when claude CLI is absent (#463)
Claude Code installed only as a VS Code/Cursor extension exposes no 'claude' binary on PATH, so the CLI strategy reported it red and Configure failed — even though 'claude mcp add --scope user' just writes an mcpServers entry into ~/.claude.json, which the user can (and did) add by hand. Make claude_code a CLI-preferred client with a JSON fallback: it declares its config location (~/.claude.json, mcpServers, type:http) and, when the binary isn't resolvable, Configure/Remove/status route through the shared JSON read-merge-write strategy instead. The CLI remains the primary path whenever it resolves, so nothing changes for users who have it. The fallback only triggers on a missing binary and preserves all other content in ~/.claude.json. - _base.gd: has_json_fallback() capability; is_installed() honours it. - claude_code.gd: declare path_template / server_key_path / entry_extra_fields. - client_configurator.gd: route cli dispatch to JSON when the binary is absent. - test_clients.gd: cover the fallback dispatch, descriptor, and semantics. Closes #463. https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw
1 parent e6f7571 commit eb70177

4 files changed

Lines changed: 124 additions & 6 deletions

File tree

plugin/addons/godot_ai/client_configurator.gd

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,11 @@ static func check_status_details_for_url_with_cli_path(id: String, url: String,
206206
var client := ClientRegistry.get_by_id(id)
207207
if client == null:
208208
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
209-
if client.config_type == "cli" and cli_path.is_empty():
209+
# A cli client with no resolved binary normally reads as NOT_CONFIGURED.
210+
# Skip that shortcut when the client has a JSON fallback (#463): the
211+
# dispatch below reads its config file directly so the status dot reflects
212+
# a fallback-configured entry instead of always showing red.
213+
if client.config_type == "cli" and cli_path.is_empty() and not client.has_json_fallback():
210214
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
211215
return _dispatch_check_status_with_cli_path_details(client, url, cli_path)
212216

@@ -219,7 +223,9 @@ static func client_status_probe_snapshot(id: String) -> Dictionary:
219223
var installed := false
220224
if client.config_type == "cli":
221225
cli_path = CliStrategy.resolve_cli_path(client)
222-
installed = not cli_path.is_empty()
226+
# #463: a JSON-fallback cli client (Claude Code as a VS Code extension)
227+
# is "installed" when its fallback config exists, even with no binary.
228+
installed = not cli_path.is_empty() or client.is_installed()
223229
else:
224230
installed = client.is_installed()
225231
return {"id": id, "cli_path": cli_path, "installed": installed}
@@ -247,6 +253,10 @@ static func _dispatch_configure(client: Client, url: String) -> Dictionary:
247253
"toml":
248254
return TomlStrategy.configure(client, SERVER_NAME, url)
249255
"cli":
256+
# #463: fall back to writing the config file directly when the CLI
257+
# binary isn't on PATH (Claude Code as a VS Code/Cursor extension).
258+
if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
259+
return JsonStrategy.configure(client, SERVER_NAME, url)
250260
return CliStrategy.configure(client, SERVER_NAME, url)
251261
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
252262

@@ -258,6 +268,10 @@ static func _dispatch_remove(client: Client) -> Dictionary:
258268
"toml":
259269
return TomlStrategy.remove(client, SERVER_NAME)
260270
"cli":
271+
# #463: mirror the configure fallback so Remove also works without
272+
# the CLI binary — otherwise a fallback-written entry is unremovable.
273+
if client.has_json_fallback() and CliStrategy.resolve_cli_path(client).is_empty():
274+
return JsonStrategy.remove(client, SERVER_NAME)
261275
return CliStrategy.remove(client, SERVER_NAME)
262276
return {"status": "error", "message": "Unknown config_type for %s: %s" % [client.id, client.config_type]}
263277

@@ -277,9 +291,12 @@ static func _dispatch_check_status_with_cli_path_details(client: Client, url: St
277291
"toml":
278292
return {"status": TomlStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
279293
"cli":
280-
if cli_path.is_empty():
281-
return CliStrategy.check_status_details(client, SERVER_NAME, url, CliStrategy.resolve_cli_path(client))
282-
return CliStrategy.check_status_details(client, SERVER_NAME, url, cli_path)
294+
var resolved_cli := cli_path if not cli_path.is_empty() else CliStrategy.resolve_cli_path(client)
295+
# #463: with no CLI binary, read the JSON fallback config so a
296+
# fallback-configured entry reports CONFIGURED instead of red.
297+
if resolved_cli.is_empty() and client.has_json_fallback():
298+
return {"status": JsonStrategy.check_status(client, SERVER_NAME, url), "error_msg": ""}
299+
return CliStrategy.check_status_details(client, SERVER_NAME, url, resolved_cli)
283300
return {"status": Client.Status.NOT_CONFIGURED, "error_msg": ""}
284301

285302

plugin/addons/godot_ai/clients/_base.gd

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,27 @@ func resolved_config_path() -> String:
120120
return McpPathTemplate.resolve(path_template)
121121

122122

123+
## True when a CLI client also declares where its config file lives, so it can
124+
## fall back to writing that file directly when the CLI binary isn't on PATH.
125+
## #463: Claude Code installed only as a VS Code / Cursor extension exposes no
126+
## `claude` binary, but `claude mcp add --scope user` just writes `mcpServers`
127+
## into ~/.claude.json — so we can produce the same entry ourselves.
128+
func has_json_fallback() -> bool:
129+
return config_type == "cli" and not path_template.is_empty() and not server_key_path.is_empty()
130+
131+
123132
## True if the user appears to have this client installed locally.
124133
func is_installed() -> bool:
125134
if config_type == "cli":
126-
return not McpCliFinder.find(_array_from_packed(cli_names)).is_empty()
135+
if not McpCliFinder.find(_array_from_packed(cli_names)).is_empty():
136+
return true
137+
# CLI not on PATH. A cli client with a JSON fallback (Claude Code as a
138+
# VS Code/Cursor extension, #463) still counts as installed if its
139+
# fallback config file already exists.
140+
if has_json_fallback():
141+
var cfg := resolved_config_path()
142+
return not cfg.is_empty() and FileAccess.file_exists(cfg)
143+
return false
127144
for p in detect_paths:
128145
var resolved := McpPathTemplate.expand(p)
129146
if not resolved.is_empty() and (FileAccess.file_exists(resolved) or DirAccess.dir_exists_absolute(resolved)):

plugin/addons/godot_ai/clients/claude_code.gd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,12 @@ func _init() -> void:
1313
)
1414
cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"])
1515
cli_status_args = PackedStringArray(["mcp", "list"])
16+
## #463: JSON fallback for when the `claude` binary isn't on PATH — e.g.
17+
## Claude Code installed only as a VS Code / Cursor extension. The CLI is
18+
## still preferred whenever it resolves; this is what gets written
19+
## otherwise. `claude mcp add --scope user --transport http` produces
20+
## exactly this shape under `mcpServers` in ~/.claude.json:
21+
## "godot-ai": { "type": "http", "url": "<url>" }
22+
path_template = {"unix": "~/.claude.json", "windows": "~/.claude.json"}
23+
server_key_path = PackedStringArray(["mcpServers"])
24+
entry_extra_fields = {"type": "http"}

test_project/tests/test_clients.gd

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,81 @@ func test_json_strategy_round_trip() -> void:
715715
assert_eq(McpJsonStrategy.check_status(client, "godot-ai", "http://127.0.0.1:8000/mcp"), McpClient.Status.NOT_CONFIGURED)
716716

717717

718+
## #463: a CLI client (Claude Code) installed only as a VS Code/Cursor
719+
## extension has no `claude` binary on PATH. With a JSON fallback declared,
720+
## Configure/Remove/status must route through the config file directly.
721+
func _make_cli_json_fallback_client(path: String) -> McpClient:
722+
var c := McpClient.new()
723+
c.id = "cli_fallback_test"
724+
c.display_name = "CLI Fallback Test"
725+
c.config_type = "cli"
726+
# A binary name that will never resolve on PATH, forcing the fallback.
727+
c.cli_names = PackedStringArray(["godot-ai-nonexistent-cli-xyz"])
728+
c.cli_register_template = PackedStringArray(["mcp", "add", "{name}", "{url}"])
729+
c.cli_unregister_template = PackedStringArray(["mcp", "remove", "{name}"])
730+
c.path_template = {"darwin": path, "windows": path, "linux": path, "unix": path}
731+
c.server_key_path = PackedStringArray(["mcpServers"])
732+
c.entry_extra_fields = {"type": "http"}
733+
return c
734+
735+
736+
func test_has_json_fallback_semantics() -> void:
737+
var path := _scratch_dir.path_join("fallback_sem.json")
738+
var with_fallback := _make_cli_json_fallback_client(path)
739+
assert_true(with_fallback.has_json_fallback(), "cli client with path_template + server_key_path should report a JSON fallback")
740+
var no_path := _make_cli_json_fallback_client(path)
741+
no_path.path_template = {}
742+
assert_false(no_path.has_json_fallback(), "cli client without path_template should not report a JSON fallback")
743+
# JSON-config clients are not "cli fallbacks".
744+
assert_false(_make_test_json_client(path).has_json_fallback(), "a plain json client should not report a cli JSON fallback")
745+
746+
747+
func test_claude_code_has_claude_json_fallback() -> void:
748+
var client := McpClientRegistry.get_by_id("claude_code")
749+
assert_true(client != null, "claude_code must be registered")
750+
assert_eq(client.config_type, "cli")
751+
assert_true(client.has_json_fallback(), "claude_code should declare a ~/.claude.json fallback (#463)")
752+
assert_eq(client.server_key_path.size(), 1)
753+
assert_eq(client.server_key_path[0], "mcpServers")
754+
assert_eq(client.entry_extra_fields.get("type"), "http", "claude mcp add --transport http writes type:http")
755+
assert_true(client.resolved_config_path().ends_with(".claude.json"), "fallback path should be ~/.claude.json, got %s" % client.resolved_config_path())
756+
757+
758+
func test_cli_fallback_dispatch_writes_json_when_binary_missing() -> void:
759+
var path := _scratch_dir.path_join("cli_fallback.json")
760+
_remove_if_exists(path)
761+
# Pre-seed an unrelated server that must survive the fallback write.
762+
var seed := {"mcpServers": {"someone-else": {"url": "http://other/"}}}
763+
var f := FileAccess.open(path, FileAccess.WRITE)
764+
f.store_string(JSON.stringify(seed))
765+
f.close()
766+
767+
var client := _make_cli_json_fallback_client(path)
768+
# The bogus cli_names never resolve, so dispatch must take the JSON fallback.
769+
var result := McpClientConfigurator._dispatch_configure(client, "http://127.0.0.1:8000/mcp")
770+
assert_eq(result.get("status"), "ok", "fallback configure should succeed: %s" % result.get("message", ""))
771+
772+
var status := McpClientConfigurator._dispatch_check_status_with_cli_path_details(client, "http://127.0.0.1:8000/mcp", "")
773+
assert_eq(status.get("status"), McpClient.Status.CONFIGURED, "fallback-configured entry should read CONFIGURED")
774+
775+
# The written entry carries type:http + url, and the other server survives.
776+
var read_file := FileAccess.open(path, FileAccess.READ)
777+
var json := JSON.new()
778+
assert_eq(json.parse(read_file.get_as_text()), OK)
779+
read_file.close()
780+
var servers: Dictionary = json.data["mcpServers"]
781+
assert_true(servers.has("someone-else"), "unrelated server entry must be preserved")
782+
var entry: Dictionary = servers["godot-ai"]
783+
assert_eq(entry.get("type"), "http", "fallback entry should pin type:http")
784+
assert_eq(entry.get("url"), "http://127.0.0.1:8000/mcp")
785+
786+
# Remove also goes through the fallback so the entry stays removable.
787+
var removed := McpClientConfigurator._dispatch_remove(client)
788+
assert_eq(removed.get("status"), "ok")
789+
var after := McpClientConfigurator._dispatch_check_status_with_cli_path_details(client, "http://127.0.0.1:8000/mcp", "")
790+
assert_eq(after.get("status"), McpClient.Status.NOT_CONFIGURED, "removed fallback entry should read NOT_CONFIGURED")
791+
792+
718793
func test_json_strategy_preserves_other_servers() -> void:
719794
var path := _scratch_dir.path_join("preserve.json")
720795
# Pre-seed the file with another server entry that must survive.

0 commit comments

Comments
 (0)