breakpoint-mcp 1.29.0 → 1.73.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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/addon/breakpoint_mcp/operations.gd +248 -13
  3. package/addon/breakpoint_mcp/plugin.cfg +1 -1
  4. package/addon/breakpoint_mcp/runtime_bridge.gd +119 -1
  5. package/dist/annotations.js +3 -3
  6. package/dist/cli/doctor.js +1 -1
  7. package/dist/cli/init.js +1 -1
  8. package/dist/cli/tools.js +2 -2
  9. package/dist/csdap.js +88 -6
  10. package/dist/dap.js +172 -10
  11. package/dist/index.js +14 -4
  12. package/dist/path-cohort.js +177 -0
  13. package/dist/paths.js +111 -0
  14. package/dist/recipes.js +1 -1
  15. package/dist/schemas.js +95 -27
  16. package/dist/tools/assetgen.js +17 -1
  17. package/dist/tools/backend.js +18 -1
  18. package/dist/tools/cli.js +41 -4
  19. package/dist/tools/csdap.js +132 -8
  20. package/dist/tools/cslsp.js +110 -5
  21. package/dist/tools/dap.js +316 -59
  22. package/dist/tools/editor/audio.js +16 -2
  23. package/dist/tools/editor/common.js +39 -1
  24. package/dist/tools/editor/filesystem.js +23 -3
  25. package/dist/tools/editor/introspection.js +42 -3
  26. package/dist/tools/editor/node.js +7 -2
  27. package/dist/tools/editor/particles.js +7 -2
  28. package/dist/tools/editor/project_input_test.js +16 -2
  29. package/dist/tools/editor/resource.js +54 -7
  30. package/dist/tools/editor/scene.js +31 -4
  31. package/dist/tools/editor/shader.js +19 -3
  32. package/dist/tools/editor/spatial.js +16 -1
  33. package/dist/tools/editor/tiles.js +16 -1
  34. package/dist/tools/editor/ui.js +29 -2
  35. package/dist/tools/editor.js +20 -13
  36. package/dist/tools/knowledge.js +14 -1
  37. package/dist/tools/lsp-common.js +29 -0
  38. package/dist/tools/lsp.js +87 -8
  39. package/dist/tools/netcode.js +47 -4
  40. package/dist/tools/processes.js +14 -2
  41. package/dist/tools/runtime.js +49 -9
  42. package/dist/tools/tabletop.js +173 -7
  43. package/dist/tools/vcs.js +138 -22
  44. package/dist/toolsets.js +2 -2
  45. package/dist/wire-defaults.js +161 -0
  46. package/package.json +9 -2
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The MCP **host** for [Breakpoint MCP](https://github.com/jlivingston-Cipher/godot-breakpoint-mcp) —
4
4
  a [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the
5
5
  Godot game engine to AI coding assistants across four planes: headless CLI, the live
6
- editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **289 tools
6
+ editor, Godot's own LSP + DAP, and a runtime bridge inside the running game. **291 tools
7
7
  + 6 MCP resources**, built against the stable `@modelcontextprotocol/sdk` 1.x API.
8
8
  Developed and tested with **Claude**; because MCP is an open protocol, other clients can
9
9
  connect too (currently untested — reports welcome).
@@ -7,7 +7,7 @@ extends RefCounted
7
7
  ## wrapped in the EditorUndoRedoManager so a human can Ctrl-Z anything the assistant did.
8
8
 
9
9
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
10
- const ADDON_VERSION := "1.9.2"
10
+ const ADDON_VERSION := "1.9.8"
11
11
 
12
12
  var _plugin: EditorPlugin
13
13
 
@@ -317,6 +317,10 @@ func dispatch(method: String, params: Dictionary) -> Dictionary:
317
317
  return _ok(_selection_get())
318
318
  "selection.set":
319
319
  return _selection_set(params)
320
+ "main_screen.get":
321
+ return _main_screen_get()
322
+ "main_screen.set":
323
+ return _main_screen_set(params)
320
324
  "classdb.get_class":
321
325
  return _classdb_get_class(params)
322
326
  "classdb.reference":
@@ -683,6 +687,119 @@ func _selection_set(params: Dictionary) -> Dictionary:
683
687
  return _ok({"selection": applied})
684
688
 
685
689
 
690
+ # ------------------------------------------------- main-screen tab ----------
691
+ # Godot keeps the INACTIVE main-screen tab's viewport alive at its minimum size,
692
+ # so screenshot_editor on the wrong tab "succeeds" and returns a 2x2 placeholder.
693
+ # The host has guarded that since #139 by rejecting anything under 8px, but the
694
+ # error could only tell the caller to switch tabs by hand -- there was no tool to
695
+ # do it with, which is a dead end for the assistant this addon exists to serve.
696
+ #
697
+ # Tab names are NOT hardcoded anywhere here. The engine reports them, they differ
698
+ # between versions (Godot 4.6 added "Game"), and a caller that asks for one that
699
+ # does not exist gets the live list back in the error. `_main_screen_set` matches
700
+ # case-insensitively so "2d" works as well as the engine's own "2D".
701
+ #
702
+ # Both EditorInterface methods are reached through call()/has_method rather than
703
+ # literally, for the reason spelled out on _scene_close: a literal call to a
704
+ # method the running engine lacks fails at PARSE time and takes the WHOLE addon
705
+ # down on that version, not just this tool. get_editor_main_screen is 4.1+ and
706
+ # set_main_screen_editor is 4.0+, so both should be present across this repo's
707
+ # 4.3/4.5/4.7 matrix -- the guard is here so that being wrong about that degrades
708
+ # to one tool reporting `unsupported` instead of the plugin failing to load.
709
+ func _main_screen_root() -> Node:
710
+ if not EditorInterface.has_method("get_editor_main_screen"):
711
+ return null
712
+ return EditorInterface.call("get_editor_main_screen")
713
+
714
+
715
+ # The container's children do NOT carry the tab names. Measured on 4.7, the five
716
+ # children are named @CanvasItemEditor@10149, @Node3DEditor@10909, two
717
+ # @WindowWrapper@... and @EditorAssetLibrary@... -- Godot's auto-generated node
718
+ # names, which `set_main_screen_editor` does not accept. The tab name it DOES
719
+ # accept ("2D", "3D", "Script", "Game", "AssetLib") is the EditorPlugin's name,
720
+ # which is not on the control. So the label is derived from the control's class:
721
+ const _MAIN_SCREEN_LABELS := {
722
+ "CanvasItemEditor": "2D",
723
+ "Node3DEditor": "3D",
724
+ "ScriptEditor": "Script",
725
+ "GameView": "Game",
726
+ "EditorAssetLibrary": "AssetLib",
727
+ }
728
+
729
+
730
+ func _main_screen_label(c: Node) -> String:
731
+ var cls := c.get_class()
732
+ if _MAIN_SCREEN_LABELS.has(cls):
733
+ return String(_MAIN_SCREEN_LABELS[cls])
734
+ # Script and Game are wrapped in a WindowWrapper (the "make floating" feature),
735
+ # so the identifying class is one level down. Measured: ['Window','ScriptEditor']
736
+ # and ['Window','GameView'].
737
+ for k in c.get_children():
738
+ var kcls := k.get_class()
739
+ if _MAIN_SCREEN_LABELS.has(kcls):
740
+ return String(_MAIN_SCREEN_LABELS[kcls])
741
+ # A third-party EditorPlugin main screen, or a future built-in this map predates.
742
+ # Its real tab name is the plugin's get_plugin_name(), which is not reachable from
743
+ # here -- so report something honest rather than inventing one. set_main_screen_editor
744
+ # is still given the caller's string verbatim, so such a tab remains switchable by
745
+ # name even though this list cannot spell it.
746
+ var nm := String(c.name)
747
+ return cls if nm.begins_with("@") else nm
748
+
749
+
750
+ func _main_screen_state() -> Dictionary:
751
+ # The active tab is the one visible child; the editor shows exactly one at a time.
752
+ # Reported as null rather than guessed if somehow none is visible.
753
+ var mains := _main_screen_root()
754
+ var available: Array = []
755
+ var active: Variant = null
756
+ if mains == null:
757
+ return {"active": null, "available": available}
758
+ for c in mains.get_children():
759
+ var label := _main_screen_label(c)
760
+ available.append(label)
761
+ if c is Control and (c as Control).visible:
762
+ active = label
763
+ return {"active": active, "available": available}
764
+
765
+
766
+ func _main_screen_get() -> Dictionary:
767
+ if _main_screen_root() == null:
768
+ return _err("unsupported", "main_screen_get requires EditorInterface.get_editor_main_screen (Godot 4.1+)")
769
+ return _ok(_main_screen_state())
770
+
771
+
772
+ func _main_screen_set(params: Dictionary) -> Dictionary:
773
+ if not EditorInterface.has_method("set_main_screen_editor"):
774
+ return _err("unsupported", "main_screen_set requires EditorInterface.set_main_screen_editor")
775
+ var state := _main_screen_state()
776
+ var available: Array = state.get("available", [])
777
+ var want := String(params.get("name", ""))
778
+ if want == "":
779
+ return _err("bad_params", "name is required (one of: %s)" % ", ".join(PackedStringArray(available)))
780
+ # Resolve case-insensitively against the derived labels so "2d" works, but fall
781
+ # back to the caller's string verbatim when nothing matches. The label map cannot
782
+ # spell a third-party plugin's main screen (see _main_screen_label), and the
783
+ # engine's own lookup can -- so a name this addon does not recognise is still
784
+ # WORTH TRYING rather than rejected here.
785
+ var target := want
786
+ for nm in available:
787
+ if String(nm).to_lower() == want.to_lower():
788
+ target = String(nm)
789
+ break
790
+ EditorInterface.call("set_main_screen_editor", target)
791
+ # Verify by READ-BACK, not by trusting the call. set_main_screen_editor returns
792
+ # nothing and silently does nothing for a name it does not know, so this is the
793
+ # only way to tell a successful switch from a typo -- and it is what makes the
794
+ # verbatim fallback above safe. It also means the caller's next move (usually a
795
+ # screenshot) acts on what the editor actually did.
796
+ var after := _main_screen_state()
797
+ var now := String(after.get("active", ""))
798
+ if now.to_lower() != target.to_lower():
799
+ return _err("not_found", "No main-screen tab named \"%s\" — the editor is still on \"%s\". Available: %s" % [want, now, ", ".join(PackedStringArray(available))])
800
+ return _ok({"active": after.get("active"), "available": after.get("available"), "requested": target})
801
+
802
+
686
803
  func _classdb_get_class(params: Dictionary) -> Dictionary:
687
804
  var cls := String(params.get("class_name", ""))
688
805
  if cls == "" or not ClassDB.class_exists(cls):
@@ -1294,10 +1411,71 @@ func _scene_get_dependencies(params: Dictionary) -> Dictionary:
1294
1411
  return _err("bad_params", "Current scene has no saved path yet")
1295
1412
  if not ResourceLoader.exists(target):
1296
1413
  return _err("not_found", "Scene not found: %s" % target)
1414
+ # 🔴 THE ENTRY GODOT HANDS BACK IS NOT A PATH, AND FOR SOME DEPENDENCIES IT IS THE
1415
+ # ONLY SPELLING THAT DOES NOT LOAD (session 169 §5). Measured on 4.7 against the
1416
+ # example project, with controls:
1417
+ #
1418
+ # res://main.tscn -> "uid://ccgi4n26nbyku::::res://player.gd" (2 segments)
1419
+ # res://demo/demo.tscn -> "res://demo/demo_snowman.gd" (1 segment)
1420
+ #
1421
+ # The shape is HETEROGENEOUS — it depends on whether the dependency has a `.uid`
1422
+ # sidecar — so `dependencies` was an array a caller could not parse without already
1423
+ # knowing the rule. Fed to resource_load verbatim, the two-segment form answers
1424
+ # `not_found`, while BOTH of its halves load fine on their own. The tool was handing
1425
+ # out the one spelling that works with nothing.
1426
+ #
1427
+ # This is #181's defect in a read-only tool: a value echoed from the engine and
1428
+ # presented as an answer nobody had measured. Same fix as #181 — the field is made to
1429
+ # mean what its description says, and nothing is destroyed: the engine's own encoding
1430
+ # is preserved verbatim under `dependencies_raw`.
1431
+ #
1432
+ # 🔴 THE FALLBACK NEVER DROPS AN ENTRY. An unrecognised shape (no res:// or user://
1433
+ # segment) is passed through whole rather than skipped: a splitter that silently
1434
+ # discards what it does not understand would shrink the dependency list and look
1435
+ # cleaner for it, which is this session's entire subject.
1297
1436
  var deps: Array = []
1437
+ var raw: Array = []
1438
+ var uids: Array = []
1298
1439
  for d in ResourceLoader.get_dependencies(target):
1299
- deps.append(String(d))
1300
- return _ok({"path": target, "dependencies": deps})
1440
+ var entry := String(d)
1441
+ var split := split_dependency_entry(entry)
1442
+ raw.append(entry)
1443
+ deps.append(split["path"])
1444
+ uids.append(split["uid"])
1445
+ return _ok({
1446
+ "path": target,
1447
+ "dependencies": deps,
1448
+ "dependencies_raw": raw,
1449
+ "dependency_uids": uids,
1450
+ })
1451
+
1452
+
1453
+ ## Split one ResourceLoader.get_dependencies entry into its loadable path and its UID.
1454
+ ##
1455
+ ## 🔴 A SEPARATE, PUBLIC FUNCTION ON PURPOSE. Inlined in the loop, two of its branches
1456
+ ## were unreachable from any scene the example project contains — a mutation sweep proved
1457
+ ## it (169 §7): making the splitter DROP unrecognised entries, and matching the UID prefix
1458
+ ## as loosely as `begins_with("u")`, both SURVIVED with the suite fully green, because no
1459
+ ## fixture produces an entry that exercises either path. The comment claiming "the
1460
+ ## fallback never drops an entry" was itself an unasserted claim, which is this session's
1461
+ ## whole subject. Pulled out here, every branch takes a synthetic input in one cheap
1462
+ ## hermetic test.
1463
+ ##
1464
+ ## Returns {"path": String, "uid": String}. `uid` is "" when the entry carries none.
1465
+ ## An entry with no recognisable path segment is returned WHOLE as the path: dropping it
1466
+ ## would shrink the dependency list and look tidier for it.
1467
+ static func split_dependency_entry(entry: String) -> Dictionary:
1468
+ var found_path := ""
1469
+ var found_uid := ""
1470
+ for s in entry.split("::::"):
1471
+ var seg := String(s)
1472
+ if seg.begins_with("res://") or seg.begins_with("user://"):
1473
+ if found_path == "":
1474
+ found_path = seg
1475
+ elif seg.begins_with("uid://"):
1476
+ if found_uid == "":
1477
+ found_uid = seg
1478
+ return {"path": found_path if found_path != "" else entry, "uid": found_uid}
1301
1479
 
1302
1480
 
1303
1481
  func _scene_pack(params: Dictionary) -> Dictionary:
@@ -1624,6 +1802,18 @@ func _resource_get_import_settings(params: Dictionary) -> Dictionary:
1624
1802
  var path := String(params.get("path", ""))
1625
1803
  if path == "":
1626
1804
  return _err("bad_params", "Missing 'path'")
1805
+ # 🔴 168 (166 §5 D3), MEASURED against a live 4.7 editor: this used to test ONLY for
1806
+ # the sidecar, so `res://player.gd` (a real file with no .import) and
1807
+ # `res://no_such_file.png` (nothing at all) returned BYTE-IDENTICAL replies —
1808
+ # {"imported": false, "importer": "", "settings": {}} — and a caller had no channel
1809
+ # to tell "not imported" from "not there". A directory answered the same way.
1810
+ # The vocabulary here is NOT invented: resource_load and resource_get_property were
1811
+ # both probed for a missing path AND for a directory, and both already answer
1812
+ # `not_found` / "Resource not found: %s" for each. This joins that convention rather
1813
+ # than adding a third one. `imported: false` now means what it says — the file is
1814
+ # really there and really has no .import sidecar.
1815
+ if not FileAccess.file_exists(path):
1816
+ return _err("not_found", "Resource not found: %s" % path)
1627
1817
  var import_path := path + ".import"
1628
1818
  if not FileAccess.file_exists(import_path):
1629
1819
  return _ok({"path": path, "imported": false, "importer": "", "settings": {}})
@@ -1645,6 +1835,12 @@ func _resource_set_import_settings(params: Dictionary) -> Dictionary:
1645
1835
  var path := String(params.get("path", ""))
1646
1836
  if path == "":
1647
1837
  return _err("bad_params", "Missing 'path'")
1838
+ # 🔴 168: the same lie as D3, one tool over. A path that does not exist was refused
1839
+ # with "No .import metadata for X (not an imported asset)" — a sentence that
1840
+ # describes a file which EXISTS and is not imported. Measured: a directory got that
1841
+ # message too. `not_imported` is now reserved for what it names.
1842
+ if not FileAccess.file_exists(path):
1843
+ return _err("not_found", "Resource not found: %s" % path)
1648
1844
  var import_path := path + ".import"
1649
1845
  if not FileAccess.file_exists(import_path):
1650
1846
  return _err("not_imported", "No .import metadata for %s (not an imported asset)" % path)
@@ -1652,10 +1848,28 @@ func _resource_set_import_settings(params: Dictionary) -> Dictionary:
1652
1848
  var e := cfg.load(import_path)
1653
1849
  if e != OK:
1654
1850
  return _err("load_failed", "Could not read import metadata: %s (%d)" % [import_path, e])
1851
+ # 🔴 168 (166 §5 D4), MEASURED: `applied` lists every key the CALLER SENT, and
1852
+ # `reimported` echoes the REQUEST parameter. Setting a key to the value it already
1853
+ # held reported {"reimported": true, "settings": ["compress/mode"]} with the
1854
+ # sidecar's bytes provably unmoved (sha256 before == after). A caller reading that
1855
+ # reply cannot tell a real edit from a ceremonial one.
1856
+ #
1857
+ # `changed` is the missing channel, and it is deliberately a channel rather than a
1858
+ # behaviour change: `settings: {}` with `reimport: true` is a legitimate FORCE-REIMPORT
1859
+ # idiom (the source file moved under Godot's feet), so the reimport still runs and
1860
+ # `reimported` still reports it honestly. What was missing was the ability to say
1861
+ # "and nothing in the metadata actually moved" — which is now `changed: []`.
1655
1862
  var applied: Array = []
1863
+ var changed: Array = []
1656
1864
  for key in params.get("settings", {}):
1657
- cfg.set_value("params", String(key), Codec.decode(params["settings"][key]))
1658
- applied.append(String(key))
1865
+ var k := String(key)
1866
+ var new_value = Codec.decode(params["settings"][key])
1867
+ var had := cfg.has_section_key("params", k)
1868
+ var old_value = cfg.get_value("params", k) if had else null
1869
+ cfg.set_value("params", k, new_value)
1870
+ applied.append(k)
1871
+ if not had or old_value != new_value:
1872
+ changed.append(k)
1659
1873
  e = cfg.save(import_path)
1660
1874
  if e != OK:
1661
1875
  return _err("save_failed", "Could not write import metadata (%d)" % e)
@@ -1663,7 +1877,7 @@ func _resource_set_import_settings(params: Dictionary) -> Dictionary:
1663
1877
  if reimport:
1664
1878
  var efs := EditorInterface.get_resource_filesystem()
1665
1879
  efs.reimport_files(PackedStringArray([path]))
1666
- return _ok({"path": path, "reimported": reimport, "settings": applied})
1880
+ return _ok({"path": path, "reimported": reimport, "settings": applied, "changed": changed})
1667
1881
 
1668
1882
 
1669
1883
  # ----------------------------------------------- Group B: filesystem --------
@@ -1706,12 +1920,28 @@ func _filesystem_move(params: Dictionary) -> Dictionary:
1706
1920
  var e := dir.rename(from_path, to_path)
1707
1921
  if e != OK:
1708
1922
  return _err("move_failed", "rename() returned %d" % e)
1923
+ # 🔴 173 §10.3 — THE THIRD CONFIRMED MEMBER OF THE ECHO-MISTAKEN-FOR-A-REPORT
1924
+ # FAMILY (#181, #183, 172 D5). `dir.rename()`'s RETURN WAS DISCARDED HERE and
1925
+ # `moved_import` was set to true whenever a sidecar merely EXISTED — so the field
1926
+ # described the REQUEST ("there was an .import and I called rename") rather than the
1927
+ # outcome. A sidecar rename that failed (destination taken, permissions, a
1928
+ # case-only rename on a case-insensitive filesystem) left an ORPHANED .import beside
1929
+ # a file that had moved, stripping the moved asset of its import settings on the next
1930
+ # scan — and the report said `moved_import: true`.
1931
+ #
1932
+ # THREE OUTCOMES, TWO BOOLEANS, BOTH DERIVED FROM THE RETURN CODE. "No sidecar" and
1933
+ # "a sidecar that would not move" are different events and the caller has to be able
1934
+ # to tell them apart; one boolean can only say "not moved" for both. The move itself
1935
+ # already succeeded, so a stranded sidecar is a PARTIAL, not a failure — returning
1936
+ # _err here would say nothing happened, which is the same lie in the other direction.
1709
1937
  var moved_import := false
1938
+ var import_stranded := false
1710
1939
  if is_file and FileAccess.file_exists(from_path + ".import"):
1711
- dir.rename(from_path + ".import", to_path + ".import")
1712
- moved_import = true
1940
+ var ie := dir.rename(from_path + ".import", to_path + ".import")
1941
+ moved_import = ie == OK
1942
+ import_stranded = ie != OK
1713
1943
  EditorInterface.get_resource_filesystem().scan()
1714
- return _ok({"moved": to_path, "from": from_path, "moved_import": moved_import})
1944
+ return _ok({"moved": to_path, "from": from_path, "moved_import": moved_import, "import_stranded": import_stranded})
1715
1945
 
1716
1946
 
1717
1947
  func _filesystem_create_dir(params: Dictionary) -> Dictionary:
@@ -1723,6 +1953,11 @@ func _filesystem_create_dir(params: Dictionary) -> Dictionary:
1723
1953
  var e := DirAccess.make_dir_recursive_absolute(path)
1724
1954
  if e != OK:
1725
1955
  return _err("mkdir_failed", "make_dir_recursive_absolute() returned %d" % e)
1956
+ # 🔴 173 §10.3, same family, one step milder: `created` was the REQUESTED path echoed
1957
+ # back, and an OK return was never once confirmed against the filesystem. Re-read it,
1958
+ # so `created` names a directory that is THERE rather than one that was asked for.
1959
+ if not DirAccess.dir_exists_absolute(path):
1960
+ return _err("mkdir_failed", "make_dir_recursive_absolute() returned OK but %s does not exist" % path)
1726
1961
  EditorInterface.get_resource_filesystem().scan()
1727
1962
  return _ok({"created": path, "existed": false})
1728
1963
 
@@ -3275,7 +3510,7 @@ func _particles_set_texture(params: Dictionary) -> Dictionary:
3275
3510
  if node == null:
3276
3511
  return _err("bad_path", "Node not found: %s" % params.get("path", ""))
3277
3512
  if not (node is GPUParticles2D):
3278
- return _err("unsupported", "%s has no texture (GPUParticles3D draws meshes; texture is GPUParticles2D-only)" % node.name)
3513
+ return _err("unsupported", "%s has no texture: only GPUParticles2D has one, and GPUParticles3D draws meshes instead. Pass the path of a GPUParticles2D node." % node.name)
3279
3514
  var tex_path := String(params.get("texture_path", ""))
3280
3515
  if tex_path == "" or not ResourceLoader.exists(tex_path):
3281
3516
  return _err("not_found", "Texture not found: %s" % tex_path)
@@ -3347,7 +3582,7 @@ func _shadermaterial_create(params: Dictionary) -> Dictionary:
3347
3582
  return _err("bad_path", "Node not found: %s" % params.get("path", ""))
3348
3583
  var prop := _material_prop(node)
3349
3584
  if prop == "":
3350
- return _err("unsupported", "%s has no material slot (needs CanvasItem.material or GeometryInstance3D.material_override)" % node.name)
3585
+ return _err("unsupported", "%s has no material slot. Pass a node that has one: any CanvasItem exposes CanvasItem.material, any GeometryInstance3D exposes GeometryInstance3D.material_override." % node.name)
3351
3586
  var mat := ShaderMaterial.new()
3352
3587
  var shader_path := String(params.get("shader_path", ""))
3353
3588
  if shader_path != "":
@@ -3375,7 +3610,7 @@ func _shadermaterial_set_shader(params: Dictionary) -> Dictionary:
3375
3610
  return _err("bad_path", "Node not found: %s" % params.get("path", ""))
3376
3611
  var prop := _material_prop(node)
3377
3612
  if prop == "":
3378
- return _err("unsupported", "%s has no material slot" % node.name)
3613
+ return _err("unsupported", "%s has no material slot. Pass a node that has one: any CanvasItem exposes CanvasItem.material, any GeometryInstance3D exposes GeometryInstance3D.material_override." % node.name)
3379
3614
  var cur = node.get(prop)
3380
3615
  if not (cur is ShaderMaterial):
3381
3616
  return _err("bad_type", "%s has no ShaderMaterial (create one first)" % node.name)
@@ -3403,7 +3638,7 @@ func _shadermaterial_set_param(params: Dictionary) -> Dictionary:
3403
3638
  return _err("bad_path", "Node not found: %s" % params.get("path", ""))
3404
3639
  var prop := _material_prop(node)
3405
3640
  if prop == "":
3406
- return _err("unsupported", "%s has no material slot" % node.name)
3641
+ return _err("unsupported", "%s has no material slot. Pass a node that has one: any CanvasItem exposes CanvasItem.material, any GeometryInstance3D exposes GeometryInstance3D.material_override." % node.name)
3407
3642
  var cur = node.get(prop)
3408
3643
  if not (cur is ShaderMaterial):
3409
3644
  return _err("bad_type", "%s has no ShaderMaterial (create one first)" % node.name)
@@ -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.9.2"
6
+ version="1.9.8"
7
7
  script="plugin.gd"
@@ -14,6 +14,11 @@ extends Node
14
14
  const Codec := preload("res://addons/breakpoint_mcp/variant_json.gd")
15
15
  const DEFAULT_PORT := 9081
16
16
  const LOG_CAP := 1000
17
+ # D1a: how many error/warning entries one response may carry. A cap and not the
18
+ # whole ring: a call that provokes a thousand warnings must not turn a 200-byte
19
+ # reply into a 100 KB one. The `total` beside the list is what stops the cap
20
+ # lying about how many there were.
21
+ const ENGINE_LOG_CAP := 20
17
22
  # D6: source for the runtime-compiled Logger subclass (Godot 4.5+). Kept as a
18
23
  # string so `extends Logger` is only ever compiled where the class exists — the
19
24
  # addon stays parse-clean on Godot 4.3/4.4 (no Logger class).
@@ -35,6 +40,12 @@ const MONITORS := {
35
40
  "time/process": Performance.TIME_PROCESS,
36
41
  "time/physics_process": Performance.TIME_PHYSICS_PROCESS,
37
42
  "memory/static": Performance.MEMORY_STATIC,
43
+ # Total live ObjectDB instances — Nodes, Resources and BARE Objects alike, which is
44
+ # what makes it the only monitor here that can see a leaked non-Node. Added session
45
+ # 153 so the _node_add `not_a_node` leak fixed in the same commit is REGRESSION-LOCKED
46
+ # in CI rather than merely proved once on a laptop: node-lifecycle.integration.mjs
47
+ # drives that branch in bulk and watches this number.
48
+ "object/count": Performance.OBJECT_COUNT,
38
49
  "object/node_count": Performance.OBJECT_NODE_COUNT,
39
50
  "object/resource_count": Performance.OBJECT_RESOURCE_COUNT,
40
51
  "render/total_objects_drawn": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME,
@@ -229,9 +240,17 @@ func _handle_line(c: Dictionary, line: String) -> void:
229
240
  if method in ASYNC_METHODS:
230
241
  _dispatch_async(c, id, method, params)
231
242
  return
243
+ # D1a: the engine-error echo. The ring already receives every push_error /
244
+ # push_warning and every engine message (via the 4.5+ Logger capture), but
245
+ # nothing tied one to the CALL that provoked it — a caller had to notice
246
+ # something was wrong, then go and read runtime.get_log, then guess which
247
+ # entries were its own. Reading _log_seq either side of the dispatch answers
248
+ # that by construction: everything appended in between belongs to this call.
249
+ var seq_before := _log_seq
232
250
  var result := _dispatch(method, params)
233
251
  var response := {"id": id}
234
252
  response.merge(result)
253
+ _attach_engine_log(response, seq_before)
235
254
  _send(peer, response)
236
255
 
237
256
 
@@ -300,6 +319,51 @@ func _on_captured_log(level: String, message: String) -> void:
300
319
  _in_capture = false
301
320
 
302
321
 
322
+ ## D1a: every `error`/`warning` ring entry appended AFTER `seq_before`, i.e. during
323
+ ## the dispatch this response belongs to. Written into `result` and not beside it:
324
+ ## the host resolves a reply as `msg.result`, so a sibling key is dropped on the
325
+ ## floor — and `structuredContent` must match the tool's `outputSchema` or a
326
+ ## conforming client throws, which is why `engine_log` is declared on all 26
327
+ ## runtime output schemas rather than smuggled in.
328
+ ##
329
+ ## `isError` IS DELIBERATELY UNTOUCHED. A push_error during a call that returned
330
+ ## what it was asked for is a DIAGNOSTIC, not a failed call; promoting it would
331
+ ## make every noisy frame look like a broken tool and would change the meaning of
332
+ ## a field callers already branch on.
333
+ ##
334
+ ## Absent when there is nothing, never `[]`: an optional field that is always
335
+ ## present carries no information, and 208 §4's finding is that absent and
336
+ ## explicitly-empty are NOT the same value to a client that never materialises
337
+ ## defaults.
338
+ func _attach_engine_log(response: Dictionary, seq_before: int) -> void:
339
+ if not response.get("ok", false):
340
+ return # a failed call already carries its own error; see the note above
341
+ var result: Variant = response.get("result")
342
+ if typeof(result) != TYPE_DICTIONARY:
343
+ return
344
+ var entries: Array = []
345
+ for e in _log:
346
+ if entries.size() >= ENGINE_LOG_CAP:
347
+ break
348
+ if int(e.get("seq", 0)) <= seq_before:
349
+ continue
350
+ var lvl := String(e.get("level", ""))
351
+ if lvl == "error" or lvl == "warning":
352
+ entries.append(e)
353
+ if entries.is_empty():
354
+ return
355
+ # 🔴 THE COUNT IS SEPARATE FROM THE LIST, because the list is CAPPED. A caller
356
+ # reading twenty entries cannot tell "twenty happened" from "hundreds happened
357
+ # and you are seeing the first twenty", and the second is the case that matters.
358
+ var total := 0
359
+ for e in _log:
360
+ if int(e.get("seq", 0)) > seq_before:
361
+ var lvl2 := String(e.get("level", ""))
362
+ if lvl2 == "error" or lvl2 == "warning":
363
+ total += 1
364
+ result["engine_log"] = {"entries": entries, "total": total, "since_seq": seq_before}
365
+
366
+
303
367
  # ----------------------------------------------------------- dispatch --------
304
368
 
305
369
  func _dispatch(method: String, params: Dictionary) -> Dictionary:
@@ -456,7 +520,36 @@ func _emit_signal(params: Dictionary) -> Dictionary:
456
520
  var call_args: Array = [sig]
457
521
  for a in params.get("args", []):
458
522
  call_args.append(Codec.decode(a))
459
- node.callv("emit_signal", call_args)
523
+ # emit_signal RETURNS an Error, and discarding it made this tool answer
524
+ # {"emitted": true} for an emission the engine refused. The cause is an `args` count
525
+ # that does not match a CONNECTED callable's arity: the engine pushes its own
526
+ # "Method expected N argument(s), but called with M" into the GAME's log — not the
527
+ # caller's — and that callable never runs, so the caller fails at its NEXT assertion,
528
+ # arbitrarily far from the cause.
529
+ #
530
+ # 🔴 TWO non-OK codes, and only one of them is a failure. Measured identically on
531
+ # 4.3, 4.5 and 4.7:
532
+ #
533
+ # OK (0) a connected callable ran
534
+ # ERR_METHOD_NOT_FOUND (37) a callable IS connected and could not be invoked —
535
+ # the arity mismatch. This is the defect worth reporting.
536
+ # ERR_UNAVAILABLE (2) the signal exists but has NO connections at all.
537
+ # Emitting into the void is ordinary and must SUCCEED —
538
+ # rejecting it would break every game that emits a signal
539
+ # nothing happens to be listening to.
540
+ #
541
+ # 🔴 The honest consequence: arity is checkable ONLY when something is connected.
542
+ # With no listener, a wrong count also returns ERR_UNAVAILABLE (there is no callable
543
+ # whose arity could mismatch), so it is indistinguishable from a correct emission.
544
+ # That is a limit of what the engine reports, not something this tool can tighten,
545
+ # and the catalog says so rather than implying a guarantee that does not exist.
546
+ var err: int = node.callv("emit_signal", call_args)
547
+ if err != OK and err != ERR_UNAVAILABLE:
548
+ return _err(
549
+ "emit_failed",
550
+ "emit_signal(%s) with %d arg(s) failed: %s (%d). Check the count against the arity of the signal's connected callables."
551
+ % [sig, call_args.size() - 1, error_string(err), err]
552
+ )
460
553
  return _ok({"emitted": true})
461
554
 
462
555
 
@@ -466,6 +559,14 @@ func _inject_input(params: Dictionary) -> Dictionary:
466
559
  match kind:
467
560
  "action":
468
561
  var action := String(ev.get("action", ""))
562
+ # An action the InputMap does not know is NOT injectable. Input.action_press
563
+ # pushes its own engine error and returns, so without this guard the reply is
564
+ # {"injected": true} for a typo'd action name and the caller's NEXT assertion is
565
+ # where the failure surfaces — as far from the cause as it is possible to get.
566
+ # Found live, session 153, by the probe that first pointed this tool at a real
567
+ # InputMap. Covers both operands: an absent "action" key arrives here as "".
568
+ if not InputMap.has_action(action):
569
+ return _err("bad_action", "No such InputMap action: %s" % action)
469
570
  if bool(ev.get("pressed", true)):
470
571
  Input.action_press(action, float(ev.get("strength", 1.0)))
471
572
  else:
@@ -891,6 +992,16 @@ func _node_add(params: Dictionary) -> Dictionary:
891
992
  return _err("bad_type", "Cannot instantiate class: %s" % type_name)
892
993
  var obj: Variant = ClassDB.instantiate(type_name)
893
994
  if not (obj is Node):
995
+ # ClassDB.instantiate hands back an UNOWNED instance. A RefCounted subclass
996
+ # (Resource, Image, ...) releases itself when this reference goes out of scope;
997
+ # a bare Object does NOT, so returning here without freeing leaked one instance
998
+ # per call — which the engine reports only at exit, as "N ObjectDB instances
999
+ # were leaked". Proved session 152, measured session 153 at 51 leaked over 51
1000
+ # calls and 0 with this branch in place.
1001
+ # The RefCounted arm MUST be excluded: free() on a RefCounted is itself an error,
1002
+ # which is why the safe-looking `Resource` case hid this for so long.
1003
+ if obj is Object and not (obj is RefCounted):
1004
+ (obj as Object).free()
894
1005
  return _err("not_a_node", "%s is not a Node" % type_name)
895
1006
  child = obj
896
1007
  if child == null:
@@ -919,6 +1030,12 @@ func _node_remove(params: Dictionary) -> Dictionary:
919
1030
  ## as a bare (un-awaited) call from _handle_line so _process never blocks; it resumes on the
920
1031
  ## engine's frame signals and sends when complete.
921
1032
  func _dispatch_async(c: Dictionary, id: Variant, method: String, params: Dictionary) -> void:
1033
+ # D1a: the async lane echoes too, and it is the lane that needs it MOST — a
1034
+ # step_frames call runs the game for N frames, so it is the one dispatch here
1035
+ # that can provoke errors from code the caller never named. Handing it the
1036
+ # synchronous version's treatment is not symmetry for its own sake: a caller
1037
+ # stepping frames to reproduce a bug is exactly the caller this feature is for.
1038
+ var seq_before := _log_seq
922
1039
  var result: Dictionary
923
1040
  match method:
924
1041
  "runtime.step_frames":
@@ -929,6 +1046,7 @@ func _dispatch_async(c: Dictionary, id: Variant, method: String, params: Diction
929
1046
  if peer != null and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
930
1047
  var response := {"id": id}
931
1048
  response.merge(result)
1049
+ _attach_engine_log(response, seq_before)
932
1050
  _send(peer, response)
933
1051
 
934
1052
 
@@ -8,7 +8,7 @@ const READ_ONLY = [
8
8
  "gd_declaration", "gd_definition", "gd_diagnostics", "gd_document_color", "gd_document_highlight",
9
9
  "gd_document_link", "gd_document_symbols", "gd_folding_ranges", "gd_formatting", "gd_hover", "gd_implementation",
10
10
  "gd_references", "gd_semantic_tokens", "gd_signature_help", "gd_type_definition", "gd_workspace_symbols",
11
- "godot_output", "godot_version", "inputmap_list", "node_find", "node_get_children", "node_get_path",
11
+ "godot_output", "godot_version", "inputmap_list", "main_screen_get", "node_find", "node_get_children", "node_get_path",
12
12
  "node_get_property", "node_list_groups", "node_list_properties", "project_get_info", "project_get_setting",
13
13
  "project_list_settings", "project_search", "resource_get_import_settings", "resource_get_property",
14
14
  "resource_load", "runtime_anim_get_state", "runtime_assert_node_state", "runtime_assert_perf",
@@ -63,7 +63,7 @@ const IDEMPOTENT = [
63
63
  "gd_references", "gd_rename", "gd_semantic_tokens", "gd_signature_help", "gd_type_definition",
64
64
  "gd_workspace_symbols", "godot_export", "godot_import", "godot_output", "godot_stop", "godot_version",
65
65
  "inputmap_add_action", "inputmap_erase_action", "inputmap_list", "joint_set_bodies", "leaderboard_scaffold",
66
- "mesh_set_surface_material", "mp_scaffold_lobby", "mp_set_authority", "mp_setup_enet_peer",
66
+ "main_screen_get", "main_screen_set", "mesh_set_surface_material", "mp_scaffold_lobby", "mp_set_authority", "mp_setup_enet_peer",
67
67
  "mp_setup_webrtc_peer", "mp_wire_rpc", "node_add_to_group", "node_change_type", "node_delete", "node_find",
68
68
  "node_get_children", "node_get_path", "node_get_property", "node_list_groups", "node_list_properties",
69
69
  "node_move_child", "node_remove_from_group", "node_rename", "node_reparent", "node_set_editable_instance",
@@ -131,7 +131,7 @@ export const ALL_ANNOTATED = [
131
131
  "godot_launch_editor", "godot_output", "godot_run_headless_script", "godot_run_managed", "godot_run_project",
132
132
  "godot_stop", "godot_version", "inputmap_add_action", "inputmap_add_event", "inputmap_erase_action",
133
133
  "inputmap_list", "interact_add_drop_zone", "interact_make_draggable", "joint_create", "joint_set_bodies",
134
- "leaderboard_scaffold", "light_create", "mesh_set_surface_material", "meshinstance_create", "mp_add_spawner",
134
+ "leaderboard_scaffold", "light_create", "main_screen_get", "main_screen_set", "mesh_set_surface_material", "meshinstance_create", "mp_add_spawner",
135
135
  "mp_add_synchronizer", "mp_scaffold_lobby", "mp_set_authority", "mp_setup_enet_peer", "mp_setup_webrtc_peer",
136
136
  "mp_wire_rpc", "navagent_configure", "navregion_create", "node_add", "node_add_to_group", "node_call_method",
137
137
  "node_change_type", "node_delete", "node_duplicate", "node_find", "node_get_children", "node_get_path",
@@ -195,7 +195,7 @@ export function checkCapabilities(config) {
195
195
  status: "ok",
196
196
  severity: "info",
197
197
  detail: dropped.length === 0
198
- ? `${state} — full 289-tool surface`
198
+ ? `${state} — full 291-tool surface`
199
199
  : `${state} (secure default) — ${dropped.length} higher-trust tool(s) dropped from the surface`,
200
200
  hint: dropped.length === 0
201
201
  ? undefined
package/dist/cli/init.js CHANGED
@@ -279,7 +279,7 @@ export async function runInit(argv, deps = {}) {
279
279
  // Trust groups — surface the secure default so even a bare `init` teaches it.
280
280
  say("");
281
281
  if (privilegedGroups) {
282
- say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 289-tool surface loads.`);
282
+ say(`Higher-trust tool groups enabled: ${privilegedGroups} — the full 291-tool surface loads.`);
283
283
  }
284
284
  else {
285
285
  say("Higher-trust tool groups are OFF by default (the secure default). The assistant loads the");
package/dist/cli/tools.js CHANGED
@@ -17,8 +17,8 @@
17
17
  * container, and on a machine with no Godot installed.
18
18
  *
19
19
  * Two surfaces are reported, because the difference is the security story:
20
- * • `full` — all 289 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
21
- * • `secure-default` — 276, what an untouched install actually advertises
20
+ * • `full` — all 291 tools, what loads with BREAKPOINT_PRIVILEGED_GROUPS=all
21
+ * • `secure-default` — 278, what an untouched install actually advertises
22
22
  * `--surface` picks which one populates `tools[]`; both counts are always in the
23
23
  * header so a consumer cannot report one as the other by accident.
24
24
  */