breakpoint-mcp 1.80.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/bridge.js CHANGED
@@ -85,21 +85,32 @@ export function bridgeErrorLabel(code) {
85
85
  /**
86
86
  * The code raised when the request could not be handed to the socket at all.
87
87
  *
88
- * 🔴 **ONE WORD, TWO PRODUCERS, AND ONLY ONE OF THEM HAS A REMEDY THAT FITS (268).** The
89
- * ADDON raises `write_failed` too, from `operations.gd`, for a FILE it could not open for
90
- * writing — and `error_remedies.gd` answers it with *"Check the target path is inside the
91
- * project and not read-only; the number is a Godot Error code."* That sentence is correct
92
- * for the addon and nonsense for this site, which has no path, no file and no Godot error
93
- * code: it is a TCP socket that went away. The collision was invisible while this branch
94
- * was believed unreachable; 268 measured it reachable 200/200 and it became live.
88
+ * 🔴 **IT WAS `write_failed` UNTIL 269, AND THAT WORD HAD TWO PRODUCERS.** The ADDON
89
+ * raises `write_failed` from `operations.gd` for a FILE it could not open for writing,
90
+ * and `error_remedies.gd` answers it with *"Check the target path is inside the project
91
+ * and not read-only; the number is a Godot Error code."* — a sentence that is correct
92
+ * there and nonsense here, where there is no path, no file and no Godot error code, only
93
+ * a TCP socket that went away. 268 fixed the half a human reads and enqueued the wire
94
+ * question rather than deciding it. This is the answer.
95
95
  *
96
- * The CODE is kept and the message and remedy are made to fit — 262's disposition for an
97
- * overloaded shipped code, for the same reason: fix what the reader acts on, and leave a
98
- * wire vocabulary that callers may already branch on alone. Whether the two producers
99
- * should share a word at all is a wire question, and it is enqueued as one rather than
100
- * decided here.
96
+ * 🔴 **THE POPULATION WAS MEASURED BEFORE THE WORD WAS CHOSEN, AND IT WAS ONE.** Eleven
97
+ * codes originate in the host, fifty in the addon, and `write_failed` was the entire
98
+ * intersection — this is a single collision and not a family, which is why it is repaired
99
+ * by renaming rather than by a discriminator field on every error the wire carries.
100
+ *
101
+ * 🔴 **AND THE YOUNGER PRODUCER YIELDS, WHICH IS THE WHOLE ARGUMENT.** The addon's
102
+ * `write_failed` has crossed this wire since the addon had a wire. THIS site was believed
103
+ * unreachable until 268 measured it 200/200, and until 1.80.0 it shipped node's own
104
+ * `Cannot call write after a stream was destroyed` — a caller branching on the sentence
105
+ * this site produced was branching on a Node internal. So the cost of moving it is one
106
+ * release of exposure, and the cost of moving the other is every integration that ever
107
+ * read a file-write refusal. `send_failed` also says the true thing outright: the request
108
+ * was never SENT.
109
+ *
110
+ * Check 32's `e` arm now refuses any code raised on both sides of the wire, so the next
111
+ * collision is a refusal at `git add` and not a discovery three sessions later.
101
112
  */
102
- export const BRIDGE_WRITE_FAILED = "write_failed";
113
+ export const BRIDGE_SEND_FAILED = "send_failed";
103
114
  /**
104
115
  * The next action for a request that never reached the socket.
105
116
  *
@@ -112,7 +123,7 @@ export const BRIDGE_WRITE_FAILED = "write_failed";
112
123
  * second. 264's rule — measure N families, speak for N — read in the direction that
113
124
  * licenses a stronger sentence rather than a weaker one.
114
125
  */
115
- export const WRITE_FAILED_REMEDY = "Retry the call — the request was never sent, so nothing was applied and this is a " +
126
+ export const SEND_FAILED_REMEDY = "Retry the call — the request was never sent, so nothing was applied and this is a " +
116
127
  "first attempt rather than a second. A recurrence means the host is tearing the " +
117
128
  "connection down mid-request.";
118
129
  /**
@@ -123,7 +134,7 @@ export const WRITE_FAILED_REMEDY = "Retry the call — the request was never sen
123
134
  * Node stream and tells a caller of `node_add` nothing about their call, which is what
124
135
  * this site shipped until 268.
125
136
  */
126
- export function writeFailedMessage(method, cause) {
137
+ export function sendFailedMessage(method, cause) {
127
138
  return (`Bridge request '${method}' was never sent — the connection was torn down between ` +
128
139
  `preparing the request and writing it, so the editor did not receive it (${cause.message}).`);
129
140
  }
@@ -499,7 +510,7 @@ export class BridgeClient {
499
510
  // editor and `bridge_closed` may have been too, so neither can tell a caller to
500
511
  // retry; this one can, and 267's rule is that a next action rides in the field
501
512
  // rather than inside the message.
502
- reject(new BridgeError(BRIDGE_WRITE_FAILED, writeFailedMessage(method, err), WRITE_FAILED_REMEDY));
513
+ reject(new BridgeError(BRIDGE_SEND_FAILED, sendFailedMessage(method, err), SEND_FAILED_REMEDY));
503
514
  }
504
515
  });
505
516
  });
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.80.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.80.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",