Skip to content

Commit a74c1c9

Browse files
dsarnoclaude
andauthored
harden(plugin): stop JSON config re-sort, honor UI opt-out over falsey env (#549)
* harden(plugin): preserve JSON config fidelity + honor UI opt-out over falsey env Config/telemetry correctness fixes from the pre-release review. JSON client-config writes (#528 / TC-2): - Default JSON.stringify(sort_keys) is true, so every rewrite alphabetically reordered the user's whole file (unrelated MCP entries and, for ~/.claude.json, the entire CLI state). Pass sort_keys=false. - Godot parses every JSON number as a float, so the same round-trip re-emitted the user's integer fields as "8080.0" — rejected by strict consumers and churning every number in their other entries. A _narrow_integral_numbers pass re-narrows exactly-representable integral floats back to int before serializing so ints stay ints. (Values above 2^53 already lost precision at parse and are left as-is — byte-perfect preservation would mean not parsing the file, and such magnitudes don't occur in client configs.) Telemetry opt-out (#530 / TC-4): - _inject_telemetry_env() skipped injection whenever GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY was *present*, even set to a falsey "0". The Python server parses those truthily (falsey => enabled), so a dock UI opt-out silently shipped telemetry. Now only a *truthy* env var counts as "already disabled" (matching McpSettings.telemetry_enabled / the server); a falsey/absent value falls through so the opt-out reaches the spawn. The truthy guard is kept so post-spawn cleanup doesn't strip a user's own var. Fixed the contradicting telemetry.gd comment too. Tests (live Godot 4.6.x): clients 94/94 incl. new integer-preservation case; server_lifecycle 20/20 incl. new falsey-env opt-out case; parse clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(plugin): address Copilot review on config/telemetry - _narrow_integral_numbers: include exactly 2^53 in the narrow bound (< -> <=). 2^53 is exactly representable as a double, so it should serialize as an int; the previous strict-less bound left it as "9007199254740992.0", contradicting the comment that only values *above* 2^53 are kept as floats. - Update the _inject_telemetry_env outer doc comment to match the implementation (only a *truthy* disable env var counts as "already disabled"; a falsey "0" falls through so the UI opt-out still reaches the spawn). - Strengthen the array-int test to check each element regardless of trailing comma, so a floatified last element ("3.0" with no comma) is also caught. Live Godot 4.6.x: clients 94/94, server_lifecycle 20/20. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 94ddb09 commit a74c1c9

5 files changed

Lines changed: 101 additions & 11 deletions

File tree

plugin/addons/godot_ai/clients/_json_strategy.gd

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ static func configure(client: McpClient, server_name: String, server_url: String
2424
var existing: Variant = holder.get(server_name, null)
2525
holder[server_name] = build_entry(client, server_url, existing)
2626

27-
if not McpAtomicWrite.write(path, JSON.stringify(config, "\t")):
27+
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
2828
return {"status": "error", "message": "Cannot write to %s" % path}
2929
return {"status": "ok", "message": "%s configured (HTTP: %s)" % [client.display_name, server_url]}
3030

@@ -60,7 +60,7 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
6060
var holder := _walk_path(config, client.server_key_path)
6161
if holder is Dictionary and holder.has(server_name):
6262
holder.erase(server_name)
63-
if not McpAtomicWrite.write(path, JSON.stringify(config, "\t")):
63+
if not McpAtomicWrite.write(path, JSON.stringify(_narrow_integral_numbers(config), "\t", false)):
6464
return {"status": "error", "message": "Cannot write to %s" % path}
6565
return {"status": "ok", "message": "%s configuration removed" % client.display_name}
6666

@@ -236,3 +236,28 @@ static func _walk_path(root: Dictionary, key_path: PackedStringArray) -> Variant
236236
return null
237237
cur = cur[key]
238238
return cur
239+
240+
241+
## Godot's JSON.parse turns every JSON number into a float, so a later
242+
## JSON.stringify re-emits the user's integer fields as "8080.0" — which strict
243+
## consumers (Go's encoding/json into an int field, etc.) reject, and which
244+
## needlessly rewrites every number across the user's *other* entries. Re-narrow
245+
## exactly-representable integral floats back to int so they serialize without
246+
## the ".0". Walks dicts/arrays in place and returns the (same) value.
247+
##
248+
## Integers above 2^53 already lost precision when Godot parsed them to double,
249+
## so they're left as the float Godot produced rather than faking exactness —
250+
## byte-perfect preservation would require not parsing the file at all, and such
251+
## magnitudes don't occur in MCP client configs.
252+
static func _narrow_integral_numbers(value: Variant) -> Variant:
253+
match typeof(value):
254+
TYPE_FLOAT:
255+
if is_finite(value) and value == floor(value) and absf(value) <= 9007199254740992.0:
256+
return int(value)
257+
TYPE_DICTIONARY:
258+
for k in value:
259+
value[k] = _narrow_integral_numbers(value[k])
260+
TYPE_ARRAY:
261+
for i in value.size():
262+
value[i] = _narrow_integral_numbers(value[i])
263+
return value

plugin/addons/godot_ai/telemetry.gd

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
## 2. The `godot_ai/telemetry_enabled` EditorSetting — set through the
1616
## MCP dock and persisted between sessions.
1717
##
18-
## When telemetry is disabled, events are never buffered or sent. If an env
19-
## var is explicitly set to a non-truthy value, telemetry is enabled even if
20-
## the editor setting is false.
18+
## When telemetry is disabled, events are never buffered or sent. Only a
19+
## *truthy* env var force-disables; a falsey or absent env var falls through
20+
## to the EditorSetting (which defaults to enabled). See McpSettings.telemetry_enabled.
2121
##
2222
## Buffering: events recorded before the WebSocket is connected go into
2323
## a small bounded buffer and flush on the next `record_event` call once

plugin/addons/godot_ai/utils/server_lifecycle.gd

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -371,14 +371,18 @@ static func _live_package_path_for_message(live: Dictionary) -> String:
371371

372372
## Sets GODOT_AI_DISABLE_TELEMETRY in the process environment for the
373373
## upcoming OS.create_process call if: (a) neither GODOT_AI_DISABLE_TELEMETRY
374-
## nor DISABLE_TELEMETRY is already set, and (b) the EditorSettings key
375-
## "godot_ai/telemetry_enabled" is set to false. Returns true if the var was
374+
## nor DISABLE_TELEMETRY is already set to a *truthy* value (a falsey "0" does
375+
## NOT count — it must not suppress a dock UI opt-out), and (b) the effective
376+
## McpSettings.telemetry_enabled() is false. Returns true if the var was
376377
## injected so the caller can unset it after spawning.
377378
func _inject_telemetry_env() -> bool:
378-
## Guard: if the user or CI already set an env var (even to "false"), leave
379-
## it alone. McpSettings.telemetry_enabled() only reads the EditorSetting
380-
## when no env var overrides are present.
381-
if OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY"):
379+
## If telemetry is already disabled by a *truthy* env var, leave the env as
380+
## the user/CI set it — the post-spawn cleanup unsets what we inject, so
381+
## injecting here would strip their own var from the editor process. A
382+
## *falsey* value (e.g. DISABLE_TELEMETRY=0) must NOT count as "handled":
383+
## fall through so a dock UI opt-out still reaches the spawned server. The
384+
## truthy test mirrors McpSettings.telemetry_enabled() and the Python server.
385+
if McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY"):
382386
return false
383387
if not McpSettings.telemetry_enabled():
384388
OS.set_environment("GODOT_AI_DISABLE_TELEMETRY", "true")

test_project/tests/test_clients.gd

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,43 @@ func test_json_strategy_preserves_other_servers() -> void:
820820
assert_true(parsed["mcpServers"].has("godot-ai"), "Our entry not added")
821821

822822

823+
func test_json_strategy_preserves_integer_fields() -> void:
824+
## Godot parses every JSON number as a float; a naive round-trip re-emits the
825+
## user's integer fields (ports, counts) as "8080.0", which strict consumers
826+
## reject and which churns numbers across the user's other entries. The
827+
## strategy must re-narrow integral numbers so ints stay ints. (#528 / TC-2)
828+
var path := _scratch_dir.path_join("ints.json")
829+
var seed := {
830+
"mcpServers": {"someone-else": {"url": "http://other/", "port": 8080, "retries": 3}},
831+
"numStartups": 47,
832+
"weights": [1, 2, 3],
833+
}
834+
var f := FileAccess.open(path, FileAccess.WRITE)
835+
f.store_string(JSON.stringify(seed))
836+
f.close()
837+
838+
var client := _make_test_json_client(path)
839+
var result := McpJsonStrategy.configure(client, "godot-ai", "http://127.0.0.1:8000/mcp")
840+
assert_eq(result.get("status"), "ok")
841+
842+
var content_file := FileAccess.open(path, FileAccess.READ)
843+
var content := content_file.get_as_text()
844+
content_file.close()
845+
# Integers must survive as integers — not be floatified to "8080.0".
846+
assert_true(content.contains('"port": 8080'), "port int must be present")
847+
assert_false(content.contains('"port": 8080.0'), "port must not become 8080.0")
848+
assert_false(content.contains('"retries": 3.0'), "retries must not be floatified")
849+
assert_false(content.contains('"numStartups": 47.0'), "top-level int must not be floatified")
850+
# Check each element regardless of trailing comma/newline so a floatified
851+
# last element ("3.0" with no comma) is also caught.
852+
for floatified in ["1.0", "2.0", "3.0"]:
853+
assert_false(content.contains(floatified), "array int must not be floatified (%s)" % floatified)
854+
# Still valid JSON, other entry preserved, our entry added.
855+
var parsed = JSON.parse_string(content)
856+
assert_true(parsed["mcpServers"].has("someone-else"))
857+
assert_true(parsed["mcpServers"].has("godot-ai"))
858+
859+
823860
func test_json_strategy_refuses_to_overwrite_unparseable_file() -> void:
824861
## Regression: if the config file exists but we can't parse it (trailing
825862
## comma, stray comment, truncated write), `configure()` used to silently

test_project/tests/test_server_lifecycle.gd

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,27 @@ func test_inject_skips_when_disable_telemetry_already_present() -> void:
429429
host.free()
430430

431431
assert_false(injected, "must not inject when DISABLE_TELEMETRY is already in env")
432+
433+
434+
func test_inject_when_env_present_but_falsey_and_setting_disabled() -> void:
435+
## A falsey env value (e.g. DISABLE_TELEMETRY=0) must NOT suppress a dock UI
436+
## opt-out. The Python server parses the env truthily, so a falsey value
437+
## leaves the server *enabled* — the plugin has to inject the disable flag
438+
## so the opt-out actually reaches the spawned server. The old
439+
## has_environment() guard treated any value (even "0") as "handled" and
440+
## silently shipped telemetry against the user's UI choice. (#530)
441+
_clear_telemetry_env_vars()
442+
OS.set_environment(_TENV2, "0")
443+
EditorInterface.get_editor_settings().set_setting(
444+
McpSettings.SETTING_TELEMETRY_ENABLED, false
445+
)
446+
var host := _ManagerHostStub.new()
447+
var manager := McpServerLifecycleManagerScript.new(host)
448+
449+
var injected := manager._inject_telemetry_env()
450+
var env_present := OS.has_environment(_TENV1)
451+
host.free()
452+
_clear_telemetry_env_vars()
453+
454+
assert_true(injected, "a falsey DISABLE_TELEMETRY must not suppress the UI opt-out")
455+
assert_true(env_present, "GODOT_AI_DISABLE_TELEMETRY must be injected for the spawn")

0 commit comments

Comments
 (0)