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.
- package/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +248 -13
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +119 -1
- package/dist/annotations.js +3 -3
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +2 -2
- package/dist/csdap.js +88 -6
- package/dist/dap.js +172 -10
- package/dist/index.js +14 -4
- package/dist/path-cohort.js +177 -0
- package/dist/paths.js +111 -0
- package/dist/recipes.js +1 -1
- package/dist/schemas.js +95 -27
- package/dist/tools/assetgen.js +17 -1
- package/dist/tools/backend.js +18 -1
- package/dist/tools/cli.js +41 -4
- package/dist/tools/csdap.js +132 -8
- package/dist/tools/cslsp.js +110 -5
- package/dist/tools/dap.js +316 -59
- package/dist/tools/editor/audio.js +16 -2
- package/dist/tools/editor/common.js +39 -1
- package/dist/tools/editor/filesystem.js +23 -3
- package/dist/tools/editor/introspection.js +42 -3
- package/dist/tools/editor/node.js +7 -2
- package/dist/tools/editor/particles.js +7 -2
- package/dist/tools/editor/project_input_test.js +16 -2
- package/dist/tools/editor/resource.js +54 -7
- package/dist/tools/editor/scene.js +31 -4
- package/dist/tools/editor/shader.js +19 -3
- package/dist/tools/editor/spatial.js +16 -1
- package/dist/tools/editor/tiles.js +16 -1
- package/dist/tools/editor/ui.js +29 -2
- package/dist/tools/editor.js +20 -13
- package/dist/tools/knowledge.js +14 -1
- package/dist/tools/lsp-common.js +29 -0
- package/dist/tools/lsp.js +87 -8
- package/dist/tools/netcode.js +47 -4
- package/dist/tools/processes.js +14 -2
- package/dist/tools/runtime.js +49 -9
- package/dist/tools/tabletop.js +173 -7
- package/dist/tools/vcs.js +138 -22
- package/dist/toolsets.js +2 -2
- package/dist/wire-defaults.js +161 -0
- 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. **
|
|
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.
|
|
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
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1658
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/dist/annotations.js
CHANGED
|
@@ -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",
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
21
|
-
* • `secure-default` —
|
|
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
|
*/
|