breakpoint-mcp 1.81.0 → 1.82.1

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/dap.js CHANGED
@@ -120,9 +120,15 @@ export function unorderedHandshakeWarning(waitedMs) {
120
120
  export const DAP_RESTART_REMEDY = "Call `dbg_launch` or `dbg_attach` first — a restart reuses the parameters of a launch that already happened, and none has.";
121
121
  /**
122
122
  * Per-line breakpoint modifier fields, each gated by an adapter capability. Godot
123
- * advertises all three false AND ignores them — a "conditional" breakpoint halts
123
+ * advertises none of the three AND ignores them — a "conditional" breakpoint halts
124
124
  * unconditionally (measured live on 4.3 and 4.7), so an undropped modifier is not a
125
125
  * no-op, it is the opposite of what the caller asked for.
126
+ *
127
+ * 🔴 278 — THE KEYS ARE ABSENT, NOT `false`, AND THIS PARAGRAPH SAID `false` FOR AS LONG
128
+ * AS IT HAS EXISTED. Both read the same way through `!== true`, which is why nothing ever
129
+ * caught it; they are different facts about the adapter, and `docs/dap_capability_ledger.json`
130
+ * is where the difference is now recorded rather than described. The ledger carries the run
131
+ * the reading came from, so the next reader can check it instead of believing this comment.
126
132
  */
127
133
  export const BREAKPOINT_MODIFIER_CAPS = {
128
134
  condition: "supportsConditionalBreakpoints",
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { applyWireDefaults } from "./wire-defaults.js";
17
17
  import { applyOutputSchemas } from "./schemas.js";
18
18
  import { applyAnnotations } from "./annotations.js";
19
19
  import { applyTimeoutCaveat } from "./timeout-caveat.js";
20
+ import { packageVersion } from "./version.js";
20
21
  import { applyCapabilities, applyDroppedToolRefusal, droppedTools, registerCapabilitiesResource, selectPrivilegedGroups, } from "./capabilities.js";
21
22
  import { taskStore, TASK_CAPABILITIES } from "./tasks.js";
22
23
  import { RESOURCE_CAPABILITIES, registerResourceSubscriptions } from "./subscriptions.js";
@@ -52,7 +53,14 @@ async function main() {
52
53
  // so long jobs (export/import/headless script) support poll/await/cancel.
53
54
  // D3: also advertise resources.subscribe so clients can subscribe to
54
55
  // 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 });
56
+ // 🔴 READ, NEVER WRITTEN HERE (#248, twenty-eight sessions). This is the version every
57
+ // connected client is told at `initialize`, and it was the ONE place in host/src where
58
+ // a version was a literal — while `--version`, `lsp.ts`'s clientInfo and `cslsp.ts`'s
59
+ // all call `packageVersion()`. `version.ts` says why in its own words: the best
60
+ // outcome is not a gated literal but no literal at all, and the incident it documents
61
+ // is this exact field — twenty-odd releases announcing 0.2.0 to every language server
62
+ // they met, because a literal nobody compares to anything cannot go stale loudly.
63
+ const server = new McpServer({ name: "breakpoint-mcp", version: packageVersion() }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
56
64
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
57
65
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
58
66
  // 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() },
package/dist/tools/dap.js CHANGED
@@ -398,17 +398,18 @@ export function registerDapTools(server, dap, cfg) {
398
398
  "or a path resolving outside the Godot project root — Godot binds breakpoints only to scripts in the project it runs, so " +
399
399
  "unlike cs_dbg_set_breakpoints an absolute path outside the root is refused here too. " +
400
400
  "Feature-detected: the per-line conditions / hit_conditions / log_messages modifiers are only sent when the connected adapter " +
401
- "advertises support (supportsConditionalBreakpoints / supportsHitConditionalBreakpoints / supportsLogPoints). On an adapter that " +
402
- "advertises them unsupported (Godot advertises all three false and IGNORES them, so the breakpoint would halt unconditionally) the " +
403
- "modifier is dropped and the result includes `unsupported_modifiers` plus a `warning`. Detection happens when the breakpoints are " +
401
+ "advertises support (supportsConditionalBreakpoints / supportsHitConditionalBreakpoints / supportsLogPoints). Measured: no Godot " +
402
+ "build this project tests advertises it — 4.3-stable and 4.7-stable advertise none of the three (the keys are ABSENT rather than " +
403
+ "false) and IGNORE the modifiers, so such a breakpoint would halt unconditionally; see docs/dap_capability_ledger.json. All three " +
404
+ "are therefore dropped on every build measured, and the result includes `unsupported_modifiers` plus a `warning`. Detection happens when the breakpoints are " +
404
405
  "applied, so it covers buffered ones too: setting modifiers before a session exists returns `modifier_detection: \"deferred\"` with a " +
405
406
  "warning, and dbg_launch then reports the `unsupported_modifiers` actually dropped during the handshake.",
406
407
  inputSchema: {
407
408
  path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
408
409
  lines: z.array(z.number().int().positive()).describe("1-based line numbers"),
409
- conditions: z.array(z.string().nullable()).optional().describe("Optional per-line condition expressions (aligned to lines, use null to skip a line); break only when the expression is true"),
410
- hit_conditions: z.array(z.string().nullable()).optional().describe("Optional per-line hit expressions aligned to lines, e.g. '>3' or '%5' — break based on hit count (null to skip)"),
411
- log_messages: z.array(z.string().nullable()).optional().describe("Optional per-line log messages aligned to lines; a message turns that breakpoint into a LOGPOINT (logs and continues, never halts). {expr} interpolates (null to skip)."),
410
+ conditions: z.array(z.string().nullable()).optional().describe("Optional per-line condition expressions (aligned to lines, use null to skip a line); break only when the expression is true. Gated on supportsConditionalBreakpoints — Measured: no Godot build this project tests advertises it, so this is dropped and reported on every build measured."),
411
+ hit_conditions: z.array(z.string().nullable()).optional().describe("Optional per-line hit expressions aligned to lines, e.g. '>3' or '%5' — break based on hit count (null to skip). Gated on supportsHitConditionalBreakpoints — Measured: no Godot build this project tests advertises it, so this is dropped and reported on every build measured."),
412
+ log_messages: z.array(z.string().nullable()).optional().describe("Optional per-line log messages aligned to lines; a message turns that breakpoint into a LOGPOINT (logs and continues, never halts). {expr} interpolates (null to skip). Gated on supportsLogPoints — Measured: no Godot build this project tests advertises it, so this is dropped and reported on every build measured."),
412
413
  },
413
414
  }, async ({ path, lines, conditions, hit_conditions, log_messages }) => {
414
415
  try {
@@ -641,8 +642,11 @@ export function registerDapTools(server, dap, cfg) {
641
642
  "(DAP setExceptionBreakpoints). Pass the filter IDs to enable; call with no filters (or []) to clear them. The result echoes the " +
642
643
  "active filters and lists `available_filters` — the exception filters the connected adapter actually advertises. " +
643
644
  "Requires a running debug session. Not gated (it only configures the debugger). " +
644
- "Feature-detected: on an adapter that advertises no exceptionBreakpointFilters (e.g. Godot 4.3, which also does not answer " +
645
- "the request — it would otherwise time out) it returns a clear \"unsupported\" message WITHOUT sending anything.",
645
+ "Feature-detected: on an adapter that advertises no exceptionBreakpointFilters (which also does not answer the request — it would " +
646
+ "otherwise time out) it returns a clear \"unsupported\" message WITHOUT sending anything. Measured: no Godot build this project " +
647
+ "tests advertises it — 4.3-stable AND 4.7-stable both advertise no filters, so on a shipped engine it is unsupported surface today " +
648
+ "rather than a feature you can reach; see docs/dap_capability_ledger.json. This sentence used to name 4.3 alone as the example, " +
649
+ "which read as though a newer build had filters. None does.",
646
650
  inputSchema: {
647
651
  filters: z.array(z.string()).optional().describe("Exception filter IDs to enable (default none = clear). Choose from available_filters in the result."),
648
652
  },
@@ -782,6 +786,9 @@ export function registerDapTools(server, dap, cfg) {
782
786
  "list the valid goto targets on that line; when the line has exactly one target (or you pass `target_id`) it jumps there. " +
783
787
  "DESTRUCTIVE: skips or repeats code by moving execution — confirm with the user and keep this gated. " +
784
788
  "Feature-detected: on an adapter that does not advertise `supportsGotoTargetsRequest` it returns a clear \"unsupported\" message WITHOUT prompting. " +
789
+ "Measured: no Godot build this project tests advertises it — 4.3-stable and 4.7-stable both refuse this tool, so on a shipped engine it " +
790
+ "is unsupported surface today rather than a feature you can reach; see docs/dap_capability_ledger.json. It is kept, and kept guarded, " +
791
+ "because a capability is not a boundary and a build can start advertising it. " +
785
792
  "Refuses a source that can never carry a target — a missing file, a directory, an empty path (which resolves to the project root), " +
786
793
  "or a path resolving outside the Godot project root — the same guard dbg_set_breakpoints applies. " +
787
794
  "Only meaningful while stopped at a breakpoint.",
@@ -854,7 +861,9 @@ export function registerDapTools(server, dap, cfg) {
854
861
  "resolvable id is armed in one setDataBreakpoints call. Call with no `watch` (or []) to clear all data breakpoints. The result reports the " +
855
862
  "armed `breakpoints` (each with its resolved data_id and verified flag) and any `unresolved` variables the adapter cannot watch. " +
856
863
  "Requires a running session; NOT gated (it only configures the debugger). Feature-detected: on an adapter that does not advertise " +
857
- "`supportsDataBreakpoints` it returns a clear \"unsupported\" message without sending any request.",
864
+ "`supportsDataBreakpoints` it returns a clear \"unsupported\" message without sending any request. Measured: no Godot build this " +
865
+ "project tests advertises it — 4.3-stable and 4.7-stable both refuse this tool, so on a shipped engine it is unsupported surface " +
866
+ "today rather than a feature you can reach; see docs/dap_capability_ledger.json.",
858
867
  inputSchema: {
859
868
  watch: z.array(z.object({
860
869
  name: z.string().describe("Variable name to watch"),
@@ -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.1",
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",