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.
- package/addon/breakpoint_mcp/error_remedies.gd +6 -0
- package/addon/breakpoint_mcp/operations.gd +73 -10
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +117 -13
- package/addon/breakpoint_mcp/variant_json.gd +90 -0
- package/dist/bridge.js +27 -16
- package/dist/index.js +1 -1
- package/dist/schemas.js +32 -2
- package/dist/tools/editor/animation.js +2 -1
- package/dist/tools/editor/core.js +2 -1
- package/dist/tools/editor/node.js +2 -1
- package/dist/tools/editor/resource.js +2 -1
- package/dist/tools/editor/shader.js +2 -1
- package/dist/tools/runtime.js +3 -2
- package/package.json +1 -1
|
@@ -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
|
+
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
|
604
|
-
if
|
|
605
|
-
|
|
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
|
|
611
|
-
if
|
|
612
|
-
|
|
613
|
-
var
|
|
614
|
-
if
|
|
615
|
-
|
|
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
|
-
* 🔴 **
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|
package/dist/tools/runtime.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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",
|