breakpoint-mcp 1.81.0 → 1.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,6 +54,8 @@ const EDITOR_REMEDIES := {
54
54
  "create_failed": "Check the parent and the name in the message, then call again.",
55
55
  "mkdir_failed": "Check the parent directory is inside the project and writable; `filesystem_create_dir` creates intermediate directories, and the number is a Godot Error code.",
56
56
  "bad_source": "Create an atlas source first with `tileset_add_source`; the id above is not one.",
57
+ "set_mismatch": "Send the value tagged — `{\"__type__\": \"Vector2\", \"x\": .., \"y\": ..}` — then call again; `node_get_property` reports the type the property currently holds.",
58
+ "set_ignored": "Check the property exists on this node with `node_get_property`, and that its setter accepts the value — nothing changed.",
57
59
  "unknown_method": "Re-run `breakpoint-mcp init --force`, then reopen the project — the addon installed here is older than the host and plain `init` skips an addon that is already present.",
58
60
  "bad_request": "Send the request again with the field the message names — it was empty or unparseable.",
59
61
  "no_viewport": "Switch the editor to the tab that owns that viewport with `main_screen_set`, then call again.",
@@ -100,6 +102,10 @@ const RUNTIME_REMEDIES := {
100
102
  "instantiate_failed": "Check the class or scene loads in the editor first — it would not instantiate in the running game.",
101
103
  "cannot_remove_root": "Call `runtime_node_remove` on a child instead, or `godot_stop` to end the run — the running scene root cannot be removed.",
102
104
  "no_tree": "Call `godot_run_project` and wait for it to bind, then call again — the game is not running.",
105
+ "set_mismatch": "Send the value tagged — `{\"__type__\": \"Vector2\", \"x\": .., \"y\": ..}` — then call again; `runtime_get_property` reports the type the property currently holds.",
106
+ "set_ignored": "Check the property exists on this node with `runtime_get_property`, and that its setter accepts the value — nothing changed.",
107
+ "bad_event_field": "Send the field tagged — `{\"__type__\": \"Vector2\", \"x\": .., \"y\": ..}` — or omit it and let the event land at its default position.",
108
+ "bad_params": "Fix the parameter named above and call again — the message carries the accepted shape.",
103
109
  }
104
110
 
105
111
 
@@ -8,7 +8,7 @@ extends RefCounted
8
8
 
9
9
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
10
10
  const Remedies := preload("res://addons/breakpoint_mcp/error_remedies.gd")
11
- const ADDON_VERSION := "1.11.0"
11
+ const ADDON_VERSION := "1.12.0"
12
12
 
13
13
  var _plugin: EditorPlugin
14
14
 
@@ -648,14 +648,77 @@ func _node_set_property(params: Dictionary) -> Dictionary:
648
648
  var prop := String(params.get("property", ""))
649
649
  if prop == "":
650
650
  return _err("bad_params", "Missing 'property'")
651
- var old_value: Variant = node.get(prop)
652
- var new_value: Variant = Codec.decode(params.get("value"))
653
- var ur := _plugin.get_undo_redo()
654
- ur.create_action("Breakpoint: set %s.%s" % [node.name, prop])
655
- ur.add_do_property(node, prop, new_value)
656
- ur.add_undo_property(node, prop, old_value)
657
- ur.commit_action()
658
- return _ok({"path": _path_of(root, node), "property": prop, "value": Codec.encode(node.get(prop))})
651
+ # 🔴 SAME FAMILY AS THE RUNTIME PLANE'S `_set_property` (issue #327) — an absent
652
+ # `value` is not a null one, and letting it through wrote the property type's zero
653
+ # over whatever was there, under a success envelope. See runtime_bridge.gd for the
654
+ # measurement; this plane reached it by the same route and through UndoRedo, so the
655
+ # wipe landed in the undo history looking like a deliberate edit.
656
+ if not params.has("value"):
657
+ return _err(
658
+ "bad_params",
659
+ "Missing 'value'. Pass it explicitly — an absent value is not the same request as a null one, and writing null here would set %s to its type's zero." % prop
660
+ )
661
+ var old_value: Variant = Codec.read_property(node, prop)
662
+ var new_value: Variant = Codec.decode(params["value"])
663
+ # 🔴 TRIAL FIRST, HISTORY ONLY IF IT LANDS. The engine coerces at the moment of the
664
+ # write, so the outcome cannot be known before it happens — and a refused write must
665
+ # leave neither a changed property NOR an undo entry. Committing first and undoing
666
+ # afterwards would put a "Breakpoint: set .." action in the editor's history for an
667
+ # edit that never took, which is a worse artifact than the one being fixed.
668
+ Codec.write_property(node, prop, new_value)
669
+ var after: Variant = Codec.read_property(node, prop)
670
+ var outcome := _set_outcome(new_value, old_value, after)
671
+ Codec.write_property(node, prop, old_value)
672
+ if outcome == "applied" or outcome == "coerced":
673
+ var ur := _plugin.get_undo_redo()
674
+ ur.create_action("Breakpoint: set %s.%s" % [node.name, prop])
675
+ if Codec.is_indexed(prop):
676
+ # UndoRedo's property methods take a property NAME; a sub-property has to go
677
+ # through the indexed setter as a method call, or the action records an edit
678
+ # to a property called "position:x" that no node has.
679
+ ur.add_do_method(node, "set_indexed", NodePath(prop), new_value)
680
+ ur.add_undo_method(node, "set_indexed", NodePath(prop), old_value)
681
+ else:
682
+ ur.add_do_property(node, prop, new_value)
683
+ ur.add_undo_property(node, prop, old_value)
684
+ ur.commit_action()
685
+ after = Codec.read_property(node, prop)
686
+ if outcome == "mismatch":
687
+ return _err(
688
+ "set_mismatch",
689
+ "%s.%s holds %s after the write and %s was asked for. Nothing the caller wrote survived: rich types cross this wire as {\"__type__\": \"Vector2\", \"x\": .., \"y\": ..} and a bare map, array or string is not that."
690
+ % [_path_of(root, node), prop, _describe(after), _describe(new_value)]
691
+ )
692
+ if outcome == "ignored":
693
+ return _err(
694
+ "set_ignored",
695
+ "%s.%s is unchanged at %s after being asked for %s. The property may not exist on this node, or its setter refused the value."
696
+ % [_path_of(root, node), prop, _describe(after), _describe(new_value)]
697
+ )
698
+ var out := {"path": _path_of(root, node), "property": prop, "value": Codec.encode(after)}
699
+ if outcome == "coerced":
700
+ out["coerced"] = true
701
+ out["requested"] = Codec.encode(new_value)
702
+ return _ok(out)
703
+
704
+
705
+ ## The three outcomes of a write. Deliberately duplicated across the two planes rather
706
+ ## than hoisted into the codec: 254's per-plane split is the house rule and these two
707
+ ## scripts have separate lifetimes. See `runtime_bridge.gd::_set_outcome` for the
708
+ ## argument the three names carry.
709
+ static func _set_outcome(asked: Variant, before: Variant, after: Variant) -> String:
710
+ if Codec.values_equal(asked, after):
711
+ return "applied"
712
+ if not Codec.types_compatible(typeof(asked), typeof(after)):
713
+ return "mismatch"
714
+ if Codec.values_equal(before, after):
715
+ return "ignored"
716
+ return "coerced"
717
+
718
+
719
+ ## `type_string(typeof(v))` plus the value, for a message a reader can act on.
720
+ static func _describe(v: Variant) -> String:
721
+ return "%s %s" % [type_string(typeof(v)), JSON.stringify(Codec.encode(v))]
659
722
 
660
723
 
661
724
  func _node_get_property(params: Dictionary) -> Dictionary:
@@ -668,7 +731,7 @@ func _node_get_property(params: Dictionary) -> Dictionary:
668
731
  var prop := String(params.get("property", ""))
669
732
  if prop == "":
670
733
  return _err("bad_params", "Missing 'property'")
671
- return _ok({"path": _path_of(root, node), "property": prop, "value": Codec.encode(node.get(prop))})
734
+ return _ok({"path": _path_of(root, node), "property": prop, "value": Codec.encode(Codec.read_property(node, prop))})
672
735
 
673
736
 
674
737
  func _selection_get() -> Dictionary:
@@ -3,5 +3,5 @@
3
3
  name="Breakpoint MCP"
4
4
  description="Exposes the live Godot editor and the running game to an MCP host so an AI assistant can drive them: scene/node/resource CRUD with full undo/redo, project settings, ClassDB, editor + in-game screenshots, and an in-game runtime bridge (live SceneTree, property get/set, method calls, signals, input injection, Performance monitors). Pairs with the breakpoint-mcp MCP host."
5
5
  author="James Livingston"
6
- version="1.11.0"
6
+ version="1.12.0"
7
7
  script="plugin.gd"
@@ -20,7 +20,7 @@ extends Node
20
20
  ## and the process that is already running keeps the addon it loaded. Held in lockstep
21
21
  ## with `plugin.cfg` and with `operations.gd`'s copy by contract_check check 14, which
22
22
  ## exists because two of those literals disagreed for two releases.
23
- const ADDON_VERSION := "1.11.0"
23
+ const ADDON_VERSION := "1.12.0"
24
24
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
25
25
  const Remedies := preload("res://addons/breakpoint_mcp/error_remedies.gd")
26
26
  const DEFAULT_PORT := 9081
@@ -502,7 +502,35 @@ func _get_property(params: Dictionary) -> Dictionary:
502
502
  if node == null:
503
503
  return _err("bad_path", "Node not found: %s" % params.get("path", ""))
504
504
  var prop := String(params.get("property", ""))
505
- return _ok({"path": _path_of(node), "property": prop, "value": Codec.encode(node.get(prop))})
505
+ if prop == "":
506
+ return _err("bad_params", "Missing 'property'")
507
+ return _ok({"path": _path_of(node), "property": prop, "value": Codec.encode(Codec.read_property(node, prop))})
508
+
509
+
510
+ ## 🔴 THIS FUNCTION USED TO ANSWER WITH A READ-BACK IT NEVER COMPARED TO WHAT IT WAS
511
+ ## ASKED FOR (issue #327). Every way a write can fail to land — a String coerced into
512
+ ## a bool, an untagged `{"x":..,"y":..}` landing in a Vector2 as (0,0), an indexed path
513
+ ## read with the wrong method, a `value` key that never arrived — produced the same
514
+ ## `ok` envelope carrying the value the property ALREADY HAD. The caller's next action
515
+ ## was therefore taken against a state it believed it had set, arbitrarily far from the
516
+ ## point where nothing happened.
517
+ ##
518
+ ## 🔴 THE THREE OUTCOMES, AND ONLY ONE OF THEM IS A FAILURE OF THE WRITE:
519
+ ##
520
+ ## APPLIED the engine holds what was asked for -> ok
521
+ ## COERCED same type, different value — a setter clamped, snapped
522
+ ## or normalised it. Ordinary and its own business, but the
523
+ ## caller must be TOLD, because it is not what it asked for -> ok, coerced
524
+ ## REFUSED the property is unchanged, or holds an incompatible
525
+ ## type: nothing the caller wrote survived -> error
526
+ static func _set_outcome(asked: Variant, before: Variant, after: Variant) -> String:
527
+ if Codec.values_equal(asked, after):
528
+ return "applied"
529
+ if not Codec.types_compatible(typeof(asked), typeof(after)):
530
+ return "mismatch"
531
+ if Codec.values_equal(before, after):
532
+ return "ignored"
533
+ return "coerced"
506
534
 
507
535
 
508
536
  func _set_property(params: Dictionary) -> Dictionary:
@@ -510,8 +538,57 @@ func _set_property(params: Dictionary) -> Dictionary:
510
538
  if node == null:
511
539
  return _err("bad_path", "Node not found: %s" % params.get("path", ""))
512
540
  var prop := String(params.get("property", ""))
513
- node.set(prop, Codec.decode(params.get("value")))
514
- return _ok({"path": _path_of(node), "property": prop, "value": Codec.encode(node.get(prop))})
541
+ if prop == "":
542
+ return _err("bad_params", "Missing 'property'")
543
+ # 🔴 A MISSING `value` IS NOT A NULL VALUE, and conflating them WIPED PROPERTIES.
544
+ # `Dictionary.get("value")` answers null for a key that is not there, and
545
+ # `Object.set(prop, null)` does not refuse — the engine coerces null to the
546
+ # property type's zero. Measured on 4.7: rotation 1.25 -> 0.0, position
547
+ # (123, 456) -> (0, 0), reported as success both times. The host's own published
548
+ # input schema left `value` OPTIONAL (`z.any()` is optional in zod), so a client
549
+ # omitting it was doing exactly what it was told it could do.
550
+ if not params.has("value"):
551
+ return _err(
552
+ "bad_params",
553
+ "Missing 'value'. Pass it explicitly — an absent value is not the same request as a null one, and writing null here would set %s to its type's zero." % prop
554
+ )
555
+ var asked: Variant = Codec.decode(params["value"])
556
+ var before: Variant = Codec.read_property(node, prop)
557
+ Codec.write_property(node, prop, asked)
558
+ var after: Variant = Codec.read_property(node, prop)
559
+ var outcome := _set_outcome(asked, before, after)
560
+ # 🔴 A REFUSED WRITE PUTS THE PROPERTY BACK, and the first draft of this fix did not.
561
+ # The engine has already coerced by the time we can compare, so `{"x":.., "y":..}`
562
+ # landing in a Vector2 had ALREADY overwritten (123, 456) with (0, 0) — and reporting
563
+ # that honestly would have left the caller with an accurate message and a broken
564
+ # scene. An error the tool raises about its own write must not also BE a write.
565
+ if outcome == "mismatch" or outcome == "ignored":
566
+ Codec.write_property(node, prop, before)
567
+ if outcome == "mismatch":
568
+ return _err(
569
+ "set_mismatch",
570
+ "%s.%s holds %s after the write and %s was asked for. Nothing the caller wrote survived: rich types cross this wire as {\"__type__\": \"Vector2\", \"x\": .., \"y\": ..} and a bare map, array or string is not that."
571
+ % [_path_of(node), prop, _describe(after), _describe(asked)]
572
+ )
573
+ if outcome == "ignored":
574
+ return _err(
575
+ "set_ignored",
576
+ "%s.%s is unchanged at %s after being asked for %s. The property may not exist on this node, or its setter refused the value."
577
+ % [_path_of(node), prop, _describe(after), _describe(asked)]
578
+ )
579
+ var out := {"path": _path_of(node), "property": prop, "value": Codec.encode(after)}
580
+ if outcome == "coerced":
581
+ # The write LANDED and the engine changed it — a clamp, a snap, a normalise.
582
+ # Saying so is the difference between a tool that is honest about what it did
583
+ # and one that lets the caller assume.
584
+ out["coerced"] = true
585
+ out["requested"] = Codec.encode(asked)
586
+ return _ok(out)
587
+
588
+
589
+ ## `type_string(typeof(v))` plus the value, for a message a reader can act on.
590
+ static func _describe(v: Variant) -> String:
591
+ return "%s %s" % [type_string(typeof(v)), JSON.stringify(Codec.encode(v))]
515
592
 
516
593
 
517
594
  func _call_method(params: Dictionary) -> Dictionary:
@@ -600,25 +677,52 @@ func _inject_input(params: Dictionary) -> Dictionary:
600
677
  var mb := InputEventMouseButton.new()
601
678
  mb.button_index = int(ev.get("button", 1))
602
679
  mb.pressed = bool(ev.get("pressed", true))
603
- var pos: Variant = Codec.decode(ev.get("position"))
604
- if pos is Vector2:
605
- mb.position = pos
680
+ var pos_err := _apply_vector2(ev, "position", func(v: Vector2) -> void: mb.position = v)
681
+ if not pos_err.is_empty():
682
+ return pos_err
606
683
  Input.parse_input_event(mb)
607
684
  return _ok({"injected": true, "kind": kind})
608
685
  "mouse_motion":
609
686
  var mm := InputEventMouseMotion.new()
610
- var mpos: Variant = Codec.decode(ev.get("position"))
611
- if mpos is Vector2:
612
- mm.position = mpos
613
- var rel: Variant = Codec.decode(ev.get("relative"))
614
- if rel is Vector2:
615
- mm.relative = rel
687
+ var mpos_err := _apply_vector2(ev, "position", func(v: Vector2) -> void: mm.position = v)
688
+ if not mpos_err.is_empty():
689
+ return mpos_err
690
+ var rel_err := _apply_vector2(ev, "relative", func(v: Vector2) -> void: mm.relative = v)
691
+ if not rel_err.is_empty():
692
+ return rel_err
616
693
  Input.parse_input_event(mm)
617
694
  return _ok({"injected": true, "kind": kind})
618
695
  _:
619
696
  return _err("bad_kind", "Unknown input kind: %s" % kind)
620
697
 
621
698
 
699
+ ## Apply an OPTIONAL Vector2 field of a synthetic input event, or say why not.
700
+ ##
701
+ ## 🔴 THIS WAS `if pos is Vector2:` WITH NO ELSE, and it is the one place in the addon
702
+ ## where something really did discard silently — the shape issue #327 reported as
703
+ ## *Vector2 in mouse-motion events makes mouse-driven controls unreachable*. An
704
+ ## untagged `{"x": .., "y": ..}` decodes to a Dictionary, fails the type test, and the
705
+ ## event was injected AT THE ORIGIN with `{"injected": true}` on the way back. The
706
+ ## caller then read a game that had been clicked at (0, 0).
707
+ ##
708
+ ## Absent stays absent — these fields are genuinely optional and an omitted position
709
+ ## means *wherever the event lands by default*. PRESENT AND UNUSABLE is the error:
710
+ ## the caller aimed at something and missed by the width of the screen.
711
+ ## Returns {} when there is nothing to report, an `_err` envelope otherwise.
712
+ func _apply_vector2(ev: Dictionary, field: String, apply: Callable) -> Dictionary:
713
+ if not ev.has(field):
714
+ return {}
715
+ var decoded: Variant = Codec.decode(ev[field])
716
+ if decoded is Vector2:
717
+ apply.call(decoded as Vector2)
718
+ return {}
719
+ return _err(
720
+ "bad_event_field",
721
+ "event.%s is %s and a Vector2 is required. Rich types cross this wire tagged: {\"__type__\": \"Vector2\", \"x\": .., \"y\": ..} — a bare map, array or string is not that, and injecting the event without it would have aimed at the origin."
722
+ % [field, _describe(decoded)]
723
+ )
724
+
725
+
622
726
  func _get_monitors(params: Dictionary) -> Dictionary:
623
727
  var out := {}
624
728
  var keys: Array = params.get("keys", [])
@@ -106,3 +106,93 @@ static func decode(j: Variant) -> Variant:
106
106
  arr.append(decode(item))
107
107
  return arr
108
108
  return j
109
+
110
+
111
+ ## 🔴 WHY THE FOUR MEMBERS BELOW EXIST — issue #327, and the reporter's own diagnosis
112
+ ## was wrong in a way worth writing down. They read *vectors dropped, falsy scalars
113
+ ## dropped, indexed paths null* as one truthiness guard in this file's decode path.
114
+ ## Measured live on Godot 4.7, both sides of the wire driven separately: real `false`,
115
+ ## `0` and `0.0` round-trip correctly and always have. What they actually hit was a
116
+ ## String reaching a bool property — Godot coerces any non-empty String to `true`, so
117
+ ## `"false"` reads back as `true` — and `set_property` had no way to notice, because it
118
+ ## answered with a FRESH READ-BACK it never compared to what was asked.
119
+ ##
120
+ ## 🔴 SO THE REPAIR IS A COMPARISON AND NOT A GUARD. A guard has to enumerate the ways
121
+ ## a write can fail to land; a comparison asks the only question that matters — is the
122
+ ## value the caller asked for the value the engine now holds — and is therefore correct
123
+ ## for the failure modes nobody has met yet.
124
+
125
+
126
+ ## True when `prop` names a SUB-property rather than a property: `position:x`,
127
+ ## `material_override:shader_parameter/albedo`.
128
+ ##
129
+ ## 🔴 THE COLON FORM IS `get_indexed`'s VOCABULARY, NOT `get`'s, and the two are
130
+ ## different methods rather than two spellings of one. `Object.get("position:x")`
131
+ ## answers `null` — not an error, not a warning — which is why #327 read this as the
132
+ ## property being unreadable rather than as the wrong method being called.
133
+ static func is_indexed(prop: String) -> bool:
134
+ return prop.contains(":")
135
+
136
+
137
+ ## Read a property that may or may not be indexed.
138
+ static func read_property(o: Object, prop: String) -> Variant:
139
+ if is_indexed(prop):
140
+ return o.get_indexed(NodePath(prop))
141
+ return o.get(prop)
142
+
143
+
144
+ ## Write a property that may or may not be indexed.
145
+ static func write_property(o: Object, prop: String, v: Variant) -> void:
146
+ if is_indexed(prop):
147
+ o.set_indexed(NodePath(prop), v)
148
+ else:
149
+ o.set(prop, v)
150
+
151
+
152
+ ## Could a value of type `asked` ever be what the engine STORED as type `got`?
153
+ ##
154
+ ## 🔴 THIS IS THE LINE BETWEEN A COERCION AND A MISTAKE, and it is drawn at the type
155
+ ## rather than at the value on purpose. A setter that clamps, snaps or normalises
156
+ ## stores a DIFFERENT VALUE OF THE SAME TYPE and is doing its job — refusing that
157
+ ## would break every `Range.value` in every project. A String that lands in a bool,
158
+ ## or a Dictionary that lands in a Vector2, is a different kind of event: nothing the
159
+ ## caller wrote survived, and the engine says nothing about it.
160
+ ##
161
+ ## The three promotions below are Godot's own and are never mistakes: int <-> float,
162
+ ## String <-> StringName, and a plain Array into any of the packed array types (JSON
163
+ ## has one array and the engine has ten).
164
+ static func types_compatible(asked: int, got: int) -> bool:
165
+ if asked == got:
166
+ return true
167
+ if _is_numeric(asked) and _is_numeric(got):
168
+ return true
169
+ if _is_stringy(asked) and _is_stringy(got):
170
+ return true
171
+ if asked == TYPE_ARRAY and _is_packed_array(got):
172
+ return true
173
+ return false
174
+
175
+
176
+ static func _is_numeric(t: int) -> bool:
177
+ return t == TYPE_INT or t == TYPE_FLOAT
178
+
179
+
180
+ static func _is_stringy(t: int) -> bool:
181
+ return t == TYPE_STRING or t == TYPE_STRING_NAME
182
+
183
+
184
+ static func _is_packed_array(t: int) -> bool:
185
+ return t in [
186
+ TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,
187
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,
188
+ TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY,
189
+ ]
190
+
191
+
192
+ ## Did the engine store the value that was asked for? Numeric pairs are compared
193
+ ## approximately, because an int asked of a float property stores a float and the two
194
+ ## are the same answer.
195
+ static func values_equal(asked: Variant, got: Variant) -> bool:
196
+ if _is_numeric(typeof(asked)) and _is_numeric(typeof(got)):
197
+ return is_equal_approx(float(asked), float(got))
198
+ return asked == got
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ async function main() {
52
52
  // so long jobs (export/import/headless script) support poll/await/cancel.
53
53
  // D3: also advertise resources.subscribe so clients can subscribe to
54
54
  // godot://… resources and receive notifications/resources/updated.
55
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.81.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
55
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.82.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
56
56
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
57
57
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
58
58
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
package/dist/schemas.js CHANGED
@@ -19,6 +19,32 @@ import { z } from "zod";
19
19
  * scalar, array, or a {"__type__": ...}-tagged object — so it stays `any`.
20
20
  */
21
21
  const encodedValue = z.any();
22
+ /**
23
+ * The same Variant, on the way IN — and REQUIRED, which `z.any()` cannot be.
24
+ *
25
+ * 🔴 ISSUE #327, AND THE HALF OF IT NOBODY HAD REPORTED. `z.any()` answers true to
26
+ * zod's `isOptional()`, so every input key spelled that way was published to clients
27
+ * OUTSIDE the JSON Schema's `required` list — including `value` on seven shipped
28
+ * tools whose own descriptions call it "New value". A client omitting it was doing
29
+ * exactly what our schema told it it could do, and the addon then read
30
+ * `params.get("value")` as null and wrote the property type's ZERO over whatever was
31
+ * there. Measured on Godot 4.7: `rotation` 1.25 -> 0.0, `position` (123, 456) ->
32
+ * (0, 0), reported as success both times.
33
+ *
34
+ * 🔴 THE UNION IS THE POINT, NOT A NARROWING. It admits everything JSON can carry,
35
+ * including an explicit `null` — which is a real request (`material_override = null`)
36
+ * and is NOT the same request as sending nothing. What it stops admitting is
37
+ * ABSENCE. The addon refuses a missing key on its own side as well: a schema is the
38
+ * client's contract, and a direct socket has never had one.
39
+ */
40
+ export const requiredEncodedValue = z.union([
41
+ z.string(),
42
+ z.number(),
43
+ z.boolean(),
44
+ z.null(),
45
+ z.array(z.any()),
46
+ z.record(z.string(), z.any()),
47
+ ]);
22
48
  const capturedRaw = z.object({
23
49
  code: z.number().nullable(),
24
50
  stdout: z.string(),
@@ -184,7 +210,11 @@ export const outputSchemas = {
184
210
  node_delete: { deleted: z.string() },
185
211
  node_rename: { path: z.string(), name: z.string() },
186
212
  node_reparent: { path: z.string() },
187
- node_set_property: { path: z.string(), property: z.string(), value: encodedValue },
213
+ // `coerced`/`requested` appear only when the engine STORED something other than what
214
+ // was asked for and the type still matched — a clamp, a snap, a normalise (issue #327).
215
+ // Optional because the ordinary answer carries neither, and declared because a field the
216
+ // wire can carry and the schema cannot name is a field a strict client rejects.
217
+ node_set_property: { path: z.string(), property: z.string(), value: encodedValue, coerced: z.boolean().optional(), requested: encodedValue.optional() },
188
218
  node_get_property: { path: z.string(), property: z.string(), value: encodedValue },
189
219
  node_duplicate: { path: z.string(), name: z.string(), type: z.string() },
190
220
  node_get_children: { path: z.string(), children: z.array(z.object({ name: z.string(), type: z.string(), path: z.string() })) },
@@ -540,7 +570,7 @@ export const outputSchemas = {
540
570
  children: z.array(runtimeNode).optional(),
541
571
  },
542
572
  runtime_get_property: { ...engineLog, path: z.string(), property: z.string(), value: encodedValue },
543
- runtime_set_property: { ...engineLog, path: z.string(), property: z.string(), value: encodedValue },
573
+ runtime_set_property: { ...engineLog, path: z.string(), property: z.string(), value: encodedValue, coerced: z.boolean().optional(), requested: encodedValue.optional() },
544
574
  runtime_call_method: { ...engineLog, return: encodedValue },
545
575
  runtime_emit_signal: { ...engineLog, emitted: z.boolean() },
546
576
  runtime_inject_input: { ...engineLog, injected: z.boolean(), kind: z.string() },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { requiredEncodedValue } from "../../schemas.js";
2
3
  import { gate } from "../../confirm.js";
3
4
  /** AnimationPlayer / Animation authoring + AnimationTree state machines. */
4
5
  export function registerAnimationTools(server, call) {
@@ -56,7 +57,7 @@ export function registerAnimationTools(server, call) {
56
57
  name: z.string().describe("Animation name"),
57
58
  track: z.number().int().describe("Track index"),
58
59
  time: z.number().describe("Key time in seconds"),
59
- value: z.any().describe("Key value (JSON scalar, array, object, or a __type__-tagged Variant matching the track type)"),
60
+ value: requiredEncodedValue.describe("Key value (JSON scalar, array, object, or a __type__-tagged Variant matching the track type)"),
60
61
  transition: z.number().optional().describe("Transition curve exponent (default 1.0)"),
61
62
  library: z.string().optional().describe("Animation library name (default \"\")"),
62
63
  },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { requiredEncodedValue } from "../../schemas.js";
2
3
  import { gate } from "../../confirm.js";
3
4
  /** Editor session + core project settings (editor_* / project get/set). */
4
5
  export function registerCoreTools(server, call) {
@@ -35,7 +36,7 @@ export function registerCoreTools(server, call) {
35
36
  description: "Write a ProjectSettings value. Set save=true to persist to project.godot. DESTRUCTIVE — gated by confirmation.",
36
37
  inputSchema: {
37
38
  name: z.string().describe("ProjectSettings key"),
38
- value: z.any().describe("New value; rich types use the {\"__type__\":...} tagging convention"),
39
+ value: requiredEncodedValue.describe("New value; rich types use the {\"__type__\":...} tagging convention"),
39
40
  save: z.boolean().optional().describe("Persist to disk (default false)"),
40
41
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
41
42
  },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { requiredEncodedValue } from "../../schemas.js";
2
3
  import { gate } from "../../confirm.js";
3
4
  /** Scene-tree node authoring: add / delete / reparent / property / group ops. */
4
5
  export function registerNodeTools(server, call, guard) {
@@ -48,7 +49,7 @@ export function registerNodeTools(server, call, guard) {
48
49
  inputSchema: {
49
50
  path: z.string().describe("Node path relative to the scene root"),
50
51
  property: z.string().describe("Property name, e.g. position, modulate, text"),
51
- value: z.any().describe("New value (JSON scalar, array, object, or a __type__-tagged Variant)"),
52
+ value: requiredEncodedValue.describe("New value (JSON scalar, array, object, or a __type__-tagged Variant)"),
52
53
  },
53
54
  }, async ({ path, property, value }) => call("node.set_property", { path, property, value }));
54
55
  server.registerTool("node_get_property", {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { requiredEncodedValue } from "../../schemas.js";
2
3
  import { gate } from "../../confirm.js";
3
4
  /**
4
5
  * Resource (.tres/.res) create / load / save / property / import ops.
@@ -100,7 +101,7 @@ export function registerResourceTools(server, call, guard) {
100
101
  inputSchema: {
101
102
  path: z.string().describe("Resource res:// path"),
102
103
  property: z.string().describe("Property name"),
103
- value: z.any().describe("New value (JSON scalar or __type__-tagged Variant)"),
104
+ value: requiredEncodedValue.describe("New value (JSON scalar or __type__-tagged Variant)"),
104
105
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
105
106
  },
106
107
  }, async ({ path, property, value, confirm }) => {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { requiredEncodedValue } from "../../schemas.js";
2
3
  import { gate } from "../../confirm.js";
3
4
  /** Shader + ShaderMaterial authoring. */
4
5
  export function registerShaderTools(server, call, guard) {
@@ -68,7 +69,7 @@ export function registerShaderTools(server, call, guard) {
68
69
  inputSchema: {
69
70
  path: z.string().describe("Node path relative to the scene root"),
70
71
  param: z.string().describe("Shader uniform name"),
71
- value: z.any().describe("New value (JSON scalar or __type__-tagged Variant)"),
72
+ value: requiredEncodedValue.describe("New value (JSON scalar or __type__-tagged Variant)"),
72
73
  },
73
74
  }, async ({ path, param, value }) => call("shadermaterial.set_param", { path, param, value }));
74
75
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { requiredEncodedValue } from "../schemas.js";
2
3
  import { isTransportUnavailable, remedyClause } from "../bridge.js";
3
4
  import { MAX_PEERS } from "../peers.js";
4
5
  import { gate } from "../confirm.js";
@@ -134,7 +135,7 @@ export function registerRuntimeTools(server, runtime, peers, config) {
134
135
  server.registerTool("runtime_set_property", {
135
136
  title: "Runtime set property",
136
137
  description: "Set a property on a live node. DESTRUCTIVE (mutates running game state) — gated by confirmation. Rich types use the {\"__type__\":...} convention.",
137
- inputSchema: { path: z.string(), property: z.string(), value: z.any(), ...confirmField, ...peerField },
138
+ inputSchema: { path: z.string(), property: z.string(), value: requiredEncodedValue, ...confirmField, ...peerField },
138
139
  }, async ({ path, property, value, confirm, peer }) => {
139
140
  const blocked = await gate(server, confirm, `Set live property ${path}.${property} on ${target(peer)}`);
140
141
  if (blocked)
@@ -371,7 +372,7 @@ export function registerRuntimeTools(server, runtime, peers, config) {
371
372
  inputSchema: {
372
373
  path: z.string().describe("Node path (relative to the current scene; '/root/...' absolute allowed)"),
373
374
  property: z.string().describe("Property to read on each poll"),
374
- value: z.any().describe("Value to compare the property against (tagged-Variant form for complex types)"),
375
+ value: requiredEncodedValue.describe("Value to compare the property against (tagged-Variant form for complex types)"),
375
376
  op: z.enum(["eq", "ne", "gt", "ge", "lt", "le"]).optional().describe("Comparison operator (default eq)"),
376
377
  timeout_ms: z.number().int().positive().optional().describe("Maximum time to wait, in ms (default 5000)"),
377
378
  poll_interval_ms: z.number().int().positive().optional().describe("Delay between polls, in ms (default 100)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.81.0",
3
+ "version": "1.82.0",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",