breakpoint-mcp 1.29.0 → 1.72.7

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 (45) 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 +54 -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 +4 -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 +49 -5
  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/package.json +8 -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"
@@ -35,6 +35,12 @@ const MONITORS := {
35
35
  "time/process": Performance.TIME_PROCESS,
36
36
  "time/physics_process": Performance.TIME_PHYSICS_PROCESS,
37
37
  "memory/static": Performance.MEMORY_STATIC,
38
+ # Total live ObjectDB instances — Nodes, Resources and BARE Objects alike, which is
39
+ # what makes it the only monitor here that can see a leaked non-Node. Added session
40
+ # 153 so the _node_add `not_a_node` leak fixed in the same commit is REGRESSION-LOCKED
41
+ # in CI rather than merely proved once on a laptop: node-lifecycle.integration.mjs
42
+ # drives that branch in bulk and watches this number.
43
+ "object/count": Performance.OBJECT_COUNT,
38
44
  "object/node_count": Performance.OBJECT_NODE_COUNT,
39
45
  "object/resource_count": Performance.OBJECT_RESOURCE_COUNT,
40
46
  "render/total_objects_drawn": Performance.RENDER_TOTAL_OBJECTS_IN_FRAME,
@@ -456,7 +462,36 @@ func _emit_signal(params: Dictionary) -> Dictionary:
456
462
  var call_args: Array = [sig]
457
463
  for a in params.get("args", []):
458
464
  call_args.append(Codec.decode(a))
459
- node.callv("emit_signal", call_args)
465
+ # emit_signal RETURNS an Error, and discarding it made this tool answer
466
+ # {"emitted": true} for an emission the engine refused. The cause is an `args` count
467
+ # that does not match a CONNECTED callable's arity: the engine pushes its own
468
+ # "Method expected N argument(s), but called with M" into the GAME's log — not the
469
+ # caller's — and that callable never runs, so the caller fails at its NEXT assertion,
470
+ # arbitrarily far from the cause.
471
+ #
472
+ # 🔴 TWO non-OK codes, and only one of them is a failure. Measured identically on
473
+ # 4.3, 4.5 and 4.7:
474
+ #
475
+ # OK (0) a connected callable ran
476
+ # ERR_METHOD_NOT_FOUND (37) a callable IS connected and could not be invoked —
477
+ # the arity mismatch. This is the defect worth reporting.
478
+ # ERR_UNAVAILABLE (2) the signal exists but has NO connections at all.
479
+ # Emitting into the void is ordinary and must SUCCEED —
480
+ # rejecting it would break every game that emits a signal
481
+ # nothing happens to be listening to.
482
+ #
483
+ # 🔴 The honest consequence: arity is checkable ONLY when something is connected.
484
+ # With no listener, a wrong count also returns ERR_UNAVAILABLE (there is no callable
485
+ # whose arity could mismatch), so it is indistinguishable from a correct emission.
486
+ # That is a limit of what the engine reports, not something this tool can tighten,
487
+ # and the catalog says so rather than implying a guarantee that does not exist.
488
+ var err: int = node.callv("emit_signal", call_args)
489
+ if err != OK and err != ERR_UNAVAILABLE:
490
+ return _err(
491
+ "emit_failed",
492
+ "emit_signal(%s) with %d arg(s) failed: %s (%d). Check the count against the arity of the signal's connected callables."
493
+ % [sig, call_args.size() - 1, error_string(err), err]
494
+ )
460
495
  return _ok({"emitted": true})
461
496
 
462
497
 
@@ -466,6 +501,14 @@ func _inject_input(params: Dictionary) -> Dictionary:
466
501
  match kind:
467
502
  "action":
468
503
  var action := String(ev.get("action", ""))
504
+ # An action the InputMap does not know is NOT injectable. Input.action_press
505
+ # pushes its own engine error and returns, so without this guard the reply is
506
+ # {"injected": true} for a typo'd action name and the caller's NEXT assertion is
507
+ # where the failure surfaces — as far from the cause as it is possible to get.
508
+ # Found live, session 153, by the probe that first pointed this tool at a real
509
+ # InputMap. Covers both operands: an absent "action" key arrives here as "".
510
+ if not InputMap.has_action(action):
511
+ return _err("bad_action", "No such InputMap action: %s" % action)
469
512
  if bool(ev.get("pressed", true)):
470
513
  Input.action_press(action, float(ev.get("strength", 1.0)))
471
514
  else:
@@ -891,6 +934,16 @@ func _node_add(params: Dictionary) -> Dictionary:
891
934
  return _err("bad_type", "Cannot instantiate class: %s" % type_name)
892
935
  var obj: Variant = ClassDB.instantiate(type_name)
893
936
  if not (obj is Node):
937
+ # ClassDB.instantiate hands back an UNOWNED instance. A RefCounted subclass
938
+ # (Resource, Image, ...) releases itself when this reference goes out of scope;
939
+ # a bare Object does NOT, so returning here without freeing leaked one instance
940
+ # per call — which the engine reports only at exit, as "N ObjectDB instances
941
+ # were leaked". Proved session 152, measured session 153 at 51 leaked over 51
942
+ # calls and 0 with this branch in place.
943
+ # The RefCounted arm MUST be excluded: free() on a RefCounted is itself an error,
944
+ # which is why the safe-looking `Resource` case hid this for so long.
945
+ if obj is Object and not (obj is RefCounted):
946
+ (obj as Object).free()
894
947
  return _err("not_a_node", "%s is not a Node" % type_name)
895
948
  child = obj
896
949
  if child == null:
@@ -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
  */
package/dist/csdap.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { DapError } from "./dap.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
+ /**
5
+ * How long `start()` waits for the entry `stopped` event when `stopAtEntry` was
6
+ * requested. Bounded so an adapter that ignores stop-on-entry reports `running`
7
+ * rather than hanging the launch. Measured: netcoredbg emits it well inside a
8
+ * second on a local program, after its `configurationDone` response.
9
+ */
10
+ const ENTRY_STOP_WAIT_MS = 10000;
4
11
  /**
5
12
  * Minimal DAP client for the C#/.NET debugging plane (D4 C3) — the debugger
6
13
  * analogue of the C2 `CsLspClient`. Transport-agnostic: it drives any
@@ -40,6 +47,12 @@ export class CsDapClient extends EventEmitter {
40
47
  watches = [];
41
48
  lastStartMode = null;
42
49
  lastStartArgs = null;
50
+ /**
51
+ * The launch/attach rejection, when one arrives. Set by `start()`; read there and
52
+ * kept afterwards so a rejection that lands late is still visible rather than
53
+ * being emitted as an unlistened `error` event (which throws).
54
+ */
55
+ startFailure = null;
43
56
  capabilities = null;
44
57
  state = "disconnected";
45
58
  lastStoppedThreadId = null;
@@ -229,7 +242,23 @@ export class CsDapClient extends EventEmitter {
229
242
  await this.applyBreakpoints(path).catch(() => undefined);
230
243
  }
231
244
  }
232
- /** Full handshake: initialize → launch/attach → (breakpoints) → configurationDone. */
245
+ /**
246
+ * Full handshake: initialize → launch/attach → (breakpoints) → configurationDone.
247
+ *
248
+ * 🔴 The handshake used to declare success unconditionally. Measured against a
249
+ * real netcoredbg 3.2.0-1092 with `program: "/no/such/binary"`:
250
+ *
251
+ * >> launch << success=true
252
+ * >> configurationDone << success=false
253
+ * "Failed command 'configurationDone' : 0x80070002"
254
+ *
255
+ * `0x80070002` is ERROR_FILE_NOT_FOUND — the adapter DOES report the failure, and it
256
+ * reports it on `configurationDone`, which was `.catch(() => undefined)`-swallowed
257
+ * immediately before `state = "running"`. So `cs_dbg_launch` answered
258
+ * `isError:false state:"running"` for a session that never existed, and every tool
259
+ * afterwards failed with a bare hex code against a phantom session. The same held
260
+ * for `program: ""` and for `cs_dbg_attach` on a process id that does not exist.
261
+ */
233
262
  async start(mode, args) {
234
263
  this.lastStartMode = mode;
235
264
  this.lastStartArgs = args;
@@ -247,14 +276,67 @@ export class CsDapClient extends EventEmitter {
247
276
  this.state = "initialized";
248
277
  // Send launch/attach but don't await it yet — many adapters (netcoredbg included)
249
278
  // only resolve it after configurationDone.
250
- const startReq = this.request(mode, args);
279
+ //
280
+ // 🔴 A rejection used to be routed to `this.emit("error", err)`. Nothing in the
281
+ // host registers an `error` listener, and an unlistened `error` emit on an
282
+ // EventEmitter is an UNCAUGHT THROW — a failing launch could take the MCP server
283
+ // down. It is captured instead, and recorded on the client so a rejection that
284
+ // lands after `start()` has returned still puts the session in `terminated`
285
+ // rather than leaving it reading `running`.
286
+ this.startFailure = null;
287
+ const startReq = this.request(mode, args).then(() => undefined, (err) => {
288
+ this.startFailure = err;
289
+ this.configured = false;
290
+ this.state = "terminated";
291
+ // A DISTINCT event name, deliberately: `error` is special-cased by
292
+ // EventEmitter and throws when unlistened. This one is safe to ignore.
293
+ this.emit("start_failed", err);
294
+ });
251
295
  await onInit;
252
296
  await this.applyAllBreakpoints();
253
- await this.request("configurationDone", {}).catch(() => undefined);
297
+ // Arm the entry-stop wait BEFORE configurationDone is sent: netcoredbg can emit
298
+ // `stopped` before its configurationDone response lands, and a listener attached
299
+ // afterwards would miss it.
300
+ const wantsEntryStop = mode === "launch" && args["stopAtEntry"] === true;
301
+ const entryStop = wantsEntryStop ? this.waitEvent("stopped", ENTRY_STOP_WAIT_MS) : null;
302
+ let configureFailure = null;
303
+ await this.request("configurationDone", {}).catch((err) => {
304
+ configureFailure = err;
305
+ });
306
+ // 🔴 Only treat a configurationDone failure as fatal when the adapter ADVERTISED
307
+ // the request. An adapter that never claimed `supportsConfigurationDoneRequest`
308
+ // may legitimately reject it while the session is perfectly alive — refusing
309
+ // there would be the over-eager mirror of the bug being fixed. netcoredbg
310
+ // advertises it (measured: `supportsConfigurationDoneRequest = true`), so its
311
+ // 0x80070002 is a real answer, not an unimplemented request.
312
+ const configureDoneAdvertised = this.capabilities?.["supportsConfigurationDoneRequest"] === true;
313
+ const fatal = this.startFailure ?? (configureDoneAdvertised ? configureFailure : null);
314
+ if (fatal) {
315
+ this.configured = false;
316
+ this.state = "terminated";
317
+ void startReq;
318
+ const detail = fatal?.message ?? String(fatal);
319
+ throw new DapError(mode, `the C# debug adapter did not start the session: ${detail}. No debug session is ` +
320
+ `running — check that the program exists and is a .NET assembly (${mode} args: ` +
321
+ `${JSON.stringify(args).slice(0, 200)}).`);
322
+ }
254
323
  this.configured = true;
255
- this.state = "running";
256
- // Surface a launch/attach failure if one occurs, but don't hang on success.
257
- startReq.catch((err) => this.emit("error", err));
324
+ // 🔴 ONLY from `initialized`, never unconditionally. The adapter can emit `stopped`
325
+ // (or `terminated`) between the configurationDone RESPONSE and this line, and the
326
+ // event handler has already moved the state — a blind `= "running"` clobbers it and
327
+ // the awaited entry stop below then has nothing left to wait for. The first version
328
+ // of this fix wrote it unconditionally and passed locally for exactly that reason:
329
+ // the race window is small, and CI is where it opened.
330
+ if (this.state === "initialized")
331
+ this.state = "running";
332
+ // 🔴 `stop_on_entry` used to return before the entry stop, so the tool reported
333
+ // `running` and `threadId()` fell back to 1 while netcoredbg's real thread id is a
334
+ // large integer — cs_dbg_stack_trace answered `0x80070057` immediately after
335
+ // launch, and the IDENTICAL call succeeded 1.5s later. Waiting here is what makes
336
+ // stop-on-entry work end to end. Bounded: an adapter that ignores stopAtEntry
337
+ // simply reports `running`, as before.
338
+ if (entryStop)
339
+ await entryStop;
258
340
  }
259
341
  threadId() {
260
342
  return this.lastStoppedThreadId ?? 1;