breakpoint-mcp 1.19.0 → 1.21.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 +1 -1
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +243 -0
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/index.js +1 -1
- package/dist/recipes.js +33 -1
- package/dist/schemas.js +19 -0
- package/dist/tools/runtime.js +206 -0
- package/package.json +1 -1
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. **286 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.
|
|
10
|
+
const ADDON_VERSION := "1.9.1"
|
|
11
11
|
|
|
12
12
|
var _plugin: EditorPlugin
|
|
13
13
|
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
name="Breakpoint MCP"
|
|
4
4
|
description="Exposes the live Godot editor and the running game to an MCP host so an AI assistant can drive them: scene/node/resource CRUD with full undo/redo, project settings, ClassDB, editor + in-game screenshots, and an in-game runtime bridge (live SceneTree, property get/set, method calls, signals, input injection, Performance monitors). Pairs with the breakpoint-mcp MCP host."
|
|
5
5
|
author="James Livingston"
|
|
6
|
-
version="1.
|
|
6
|
+
version="1.9.1"
|
|
7
7
|
script="plugin.gd"
|
|
@@ -47,6 +47,10 @@ const MONITORS := {
|
|
|
47
47
|
|
|
48
48
|
const BridgeSecret := preload("res://addons/breakpoint_mcp/bridge_secret.gd")
|
|
49
49
|
const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
|
|
50
|
+
# F4: methods dispatched on the async lane (they await frames); see _handle_line / _dispatch_async.
|
|
51
|
+
const ASYNC_METHODS := ["runtime.step_frames"]
|
|
52
|
+
# F4: default salient fields captured by runtime.state_digest when no explicit list is given.
|
|
53
|
+
const _DIGEST_DEFAULT_FIELDS := ["position", "global_position", "rotation", "scale", "visible", "modulate"]
|
|
50
54
|
|
|
51
55
|
var _server: TCPServer
|
|
52
56
|
var _clients: Array = [] # Array of {peer, buf}
|
|
@@ -219,6 +223,12 @@ func _handle_line(c: Dictionary, line: String) -> void:
|
|
|
219
223
|
held.merge(PauseLatch.held_response(method))
|
|
220
224
|
_send(peer, held)
|
|
221
225
|
return
|
|
226
|
+
# F4: async lane — methods that await frames can't use the synchronous dispatch (which sends
|
|
227
|
+
# its response immediately). Start a coroutine that sends the id'd response when done; the host
|
|
228
|
+
# correlates responses by id, so an out-of-order async response is fine.
|
|
229
|
+
if method in ASYNC_METHODS:
|
|
230
|
+
_dispatch_async(c, id, method, params)
|
|
231
|
+
return
|
|
222
232
|
var result := _dispatch(method, params)
|
|
223
233
|
var response := {"id": id}
|
|
224
234
|
response.merge(result)
|
|
@@ -324,6 +334,27 @@ func _dispatch(method: String, params: Dictionary) -> Dictionary:
|
|
|
324
334
|
return _assert_screen_text(params)
|
|
325
335
|
"runtime.screenshot_diff":
|
|
326
336
|
return _screenshot_diff(params)
|
|
337
|
+
"runtime.anim_play":
|
|
338
|
+
return _anim_play(params)
|
|
339
|
+
"runtime.anim_stop":
|
|
340
|
+
return _anim_stop(params)
|
|
341
|
+
"runtime.anim_get_state":
|
|
342
|
+
return _anim_get_state(params)
|
|
343
|
+
"runtime.node_add":
|
|
344
|
+
return _node_add(params)
|
|
345
|
+
"runtime.node_remove":
|
|
346
|
+
return _node_remove(params)
|
|
347
|
+
"runtime.time_scale":
|
|
348
|
+
return _time_scale(params)
|
|
349
|
+
"runtime.state_digest":
|
|
350
|
+
return _state_digest(params)
|
|
351
|
+
"runtime.seed_rng":
|
|
352
|
+
return _seed_rng(params)
|
|
353
|
+
"runtime.step_frames":
|
|
354
|
+
# Dispatched on the async lane (see _handle_line / _dispatch_async); this placeholder
|
|
355
|
+
# keeps the static contract check (which scans _dispatch case labels) aware of the
|
|
356
|
+
# method. It is not reached in normal operation.
|
|
357
|
+
return _err("async_only", "runtime.step_frames is dispatched on the async lane")
|
|
327
358
|
_:
|
|
328
359
|
return _err("unknown_method", "No such method: %s" % method)
|
|
329
360
|
|
|
@@ -767,3 +798,215 @@ func _compare_images(frame: Image, reference_img: Image, params: Dictionary, ref
|
|
|
767
798
|
"height": h,
|
|
768
799
|
"reference": reference,
|
|
769
800
|
})
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# ----------------------------------------------------- F8: animation ---------
|
|
804
|
+
|
|
805
|
+
## Resolve a node and require it to be an AnimationPlayer.
|
|
806
|
+
func _resolve_anim_player(path: String):
|
|
807
|
+
var node := _resolve(path)
|
|
808
|
+
if node == null:
|
|
809
|
+
return _err("bad_path", "Node not found: %s" % path)
|
|
810
|
+
if not (node is AnimationPlayer):
|
|
811
|
+
return _err("not_animation_player", "%s is not an AnimationPlayer" % node.get_class())
|
|
812
|
+
return node as AnimationPlayer
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
func _anim_state(ap: AnimationPlayer) -> Dictionary:
|
|
816
|
+
var anims: Array = []
|
|
817
|
+
for a in ap.get_animation_list():
|
|
818
|
+
anims.append(String(a))
|
|
819
|
+
return {
|
|
820
|
+
"playing": ap.is_playing(),
|
|
821
|
+
"current_animation": ap.current_animation,
|
|
822
|
+
"position": ap.current_animation_position,
|
|
823
|
+
"length": ap.current_animation_length,
|
|
824
|
+
"speed_scale": ap.speed_scale,
|
|
825
|
+
"animations": anims,
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
func _anim_play(params: Dictionary) -> Dictionary:
|
|
830
|
+
var res = _resolve_anim_player(String(params.get("path", "")))
|
|
831
|
+
if res is Dictionary:
|
|
832
|
+
return res
|
|
833
|
+
var ap: AnimationPlayer = res
|
|
834
|
+
var anim := String(params.get("animation", ""))
|
|
835
|
+
if anim != "" and not ap.has_animation(anim):
|
|
836
|
+
return _err("no_animation", "AnimationPlayer has no animation '%s'" % anim)
|
|
837
|
+
var speed := float(params.get("custom_speed", 1.0))
|
|
838
|
+
var from_end := bool(params.get("from_end", false))
|
|
839
|
+
ap.play(anim, -1.0, speed, from_end)
|
|
840
|
+
return _ok({
|
|
841
|
+
"playing": ap.is_playing(),
|
|
842
|
+
"current_animation": ap.current_animation,
|
|
843
|
+
"speed_scale": ap.speed_scale,
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
func _anim_stop(params: Dictionary) -> Dictionary:
|
|
848
|
+
var res = _resolve_anim_player(String(params.get("path", "")))
|
|
849
|
+
if res is Dictionary:
|
|
850
|
+
return res
|
|
851
|
+
var ap: AnimationPlayer = res
|
|
852
|
+
# keep_state:true pauses in place; default stops. pause()/stop() with no args are
|
|
853
|
+
# stable across Godot 4.2–4.5 (unlike stop()'s changing keep_state parameter).
|
|
854
|
+
if bool(params.get("keep_state", false)):
|
|
855
|
+
ap.pause()
|
|
856
|
+
else:
|
|
857
|
+
ap.stop()
|
|
858
|
+
return _ok({
|
|
859
|
+
"playing": ap.is_playing(),
|
|
860
|
+
"current_animation": ap.current_animation,
|
|
861
|
+
"position": ap.current_animation_position,
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
func _anim_get_state(params: Dictionary) -> Dictionary:
|
|
866
|
+
var res = _resolve_anim_player(String(params.get("path", "")))
|
|
867
|
+
if res is Dictionary:
|
|
868
|
+
return res
|
|
869
|
+
var ap: AnimationPlayer = res
|
|
870
|
+
return _ok(_anim_state(ap))
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# ----------------------------------------------------- F8: node lifecycle ----
|
|
874
|
+
|
|
875
|
+
func _node_add(params: Dictionary) -> Dictionary:
|
|
876
|
+
var parent := _resolve(String(params.get("parent", "")))
|
|
877
|
+
if parent == null:
|
|
878
|
+
return _err("bad_path", "Parent not found: %s" % params.get("parent", ""))
|
|
879
|
+
var child: Node = null
|
|
880
|
+
var scene_path := String(params.get("scene", ""))
|
|
881
|
+
if scene_path != "":
|
|
882
|
+
var ps: Variant = load(scene_path)
|
|
883
|
+
if ps == null or not (ps is PackedScene):
|
|
884
|
+
return _err("bad_scene", "Could not load PackedScene: %s" % scene_path)
|
|
885
|
+
child = (ps as PackedScene).instantiate()
|
|
886
|
+
else:
|
|
887
|
+
var type_name := String(params.get("type", ""))
|
|
888
|
+
if type_name == "":
|
|
889
|
+
return _err("bad_args", "Provide either 'scene' or 'type'")
|
|
890
|
+
if not ClassDB.class_exists(type_name) or not ClassDB.can_instantiate(type_name):
|
|
891
|
+
return _err("bad_type", "Cannot instantiate class: %s" % type_name)
|
|
892
|
+
var obj: Variant = ClassDB.instantiate(type_name)
|
|
893
|
+
if not (obj is Node):
|
|
894
|
+
return _err("not_a_node", "%s is not a Node" % type_name)
|
|
895
|
+
child = obj
|
|
896
|
+
if child == null:
|
|
897
|
+
return _err("instantiate_failed", "Failed to instantiate node")
|
|
898
|
+
var nm := String(params.get("name", ""))
|
|
899
|
+
if nm != "":
|
|
900
|
+
child.name = nm
|
|
901
|
+
parent.add_child(child)
|
|
902
|
+
return _ok({"added": true, "path": _path_of(child), "type": child.get_class()})
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
func _node_remove(params: Dictionary) -> Dictionary:
|
|
906
|
+
var node := _resolve(String(params.get("path", "")))
|
|
907
|
+
if node == null:
|
|
908
|
+
return _err("bad_path", "Node not found: %s" % params.get("path", ""))
|
|
909
|
+
if node == _base():
|
|
910
|
+
return _err("cannot_remove_root", "Refusing to remove the current scene root")
|
|
911
|
+
var p := _path_of(node)
|
|
912
|
+
node.queue_free()
|
|
913
|
+
return _ok({"removed": true, "path": p})
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# ----------------------------------------------- F4: deterministic playtesting ----
|
|
917
|
+
|
|
918
|
+
## Async dispatch lane: run a coroutine that awaits frames, then send the id'd response. Started
|
|
919
|
+
## as a bare (un-awaited) call from _handle_line so _process never blocks; it resumes on the
|
|
920
|
+
## engine's frame signals and sends when complete.
|
|
921
|
+
func _dispatch_async(c: Dictionary, id: Variant, method: String, params: Dictionary) -> void:
|
|
922
|
+
var result: Dictionary
|
|
923
|
+
match method:
|
|
924
|
+
"runtime.step_frames":
|
|
925
|
+
result = await _step_frames(params)
|
|
926
|
+
_:
|
|
927
|
+
result = _err("async_only", "not an async method: %s" % method)
|
|
928
|
+
var peer: StreamPeerTCP = c.get("peer")
|
|
929
|
+
if peer != null and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
930
|
+
var response := {"id": id}
|
|
931
|
+
response.merge(result)
|
|
932
|
+
_send(peer, response)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
## Advance the game by an exact number of frames while otherwise frozen. Toggles
|
|
936
|
+
## get_tree().paused false -> (await one frame) -> true per step (the standard Godot frame-step
|
|
937
|
+
## idiom); the bridge keeps servicing the socket throughout because it is PROCESS_MODE_ALWAYS.
|
|
938
|
+
## Restores the caller's prior pause state when done.
|
|
939
|
+
func _step_frames(params: Dictionary) -> Dictionary:
|
|
940
|
+
var tree := get_tree()
|
|
941
|
+
if tree == null:
|
|
942
|
+
return _err("no_tree", "No scene tree")
|
|
943
|
+
var frames := clampi(int(params.get("frames", 1)), 1, 100000)
|
|
944
|
+
var kind := String(params.get("kind", "idle"))
|
|
945
|
+
var prev_scale := Engine.time_scale
|
|
946
|
+
# Advance by exactly `frames`: restore a normal time scale for the step (the caller froze via
|
|
947
|
+
# runtime_time_scale{scale:0}, which zeroes delta), and count real frames off the ENGINE frame
|
|
948
|
+
# counters. Freeze/step are driven by time_scale (delta), NOT get_tree().paused: pause is a
|
|
949
|
+
# per-node processing gate that desyncs from the frame counter by one frame at the step boundary
|
|
950
|
+
# (a node advances N-1 for N counted frames), whereas with time_scale the node processes every
|
|
951
|
+
# frame and the counter matches its work 1:1. time_scale is restored after.
|
|
952
|
+
Engine.time_scale = 1.0
|
|
953
|
+
if kind == "physics" or kind == "both":
|
|
954
|
+
var target_p := Engine.get_physics_frames() + frames
|
|
955
|
+
while Engine.get_physics_frames() < target_p:
|
|
956
|
+
await tree.physics_frame
|
|
957
|
+
else:
|
|
958
|
+
var target_i := Engine.get_process_frames() + frames
|
|
959
|
+
while Engine.get_process_frames() < target_i:
|
|
960
|
+
await tree.process_frame
|
|
961
|
+
Engine.time_scale = prev_scale
|
|
962
|
+
return _ok({"frames_advanced": frames, "frame_index": Engine.get_process_frames()})
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
func _time_scale(params: Dictionary) -> Dictionary:
|
|
966
|
+
var previous := Engine.time_scale
|
|
967
|
+
var scale := maxf(0.0, float(params.get("scale", 1.0)))
|
|
968
|
+
Engine.time_scale = scale
|
|
969
|
+
# scale 0 freezes the game clock (delta -> 0): delta-based motion, tweens, timers and
|
|
970
|
+
# animations halt. runtime_step_frames temporarily restores a normal scale to advance an exact
|
|
971
|
+
# number of frames. Deliberately time_scale, not get_tree().paused — pause is a per-node
|
|
972
|
+
# processing gate that desyncs from the frame counter by one frame at the step boundary.
|
|
973
|
+
return _ok({"previous": previous, "current": Engine.time_scale})
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
func _seed_rng(params: Dictionary) -> Dictionary:
|
|
977
|
+
var s := int(params.get("seed", 0))
|
|
978
|
+
seed(s)
|
|
979
|
+
return _ok({"seed": s})
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
## Read a compact, stable-ordered snapshot of a subtree's salient state (read-only). Default
|
|
983
|
+
## fields are the common transform/visibility properties; a caller may pass an explicit list.
|
|
984
|
+
func _state_digest(params: Dictionary) -> Dictionary:
|
|
985
|
+
var root := _resolve(String(params.get("root", "")))
|
|
986
|
+
if root == null:
|
|
987
|
+
return _err("bad_path", "Node not found: %s" % params.get("root", ""))
|
|
988
|
+
var raw_fields: Variant = params.get("fields", [])
|
|
989
|
+
var fields: Array = raw_fields if typeof(raw_fields) == TYPE_ARRAY else []
|
|
990
|
+
var max_depth := int(params.get("max_depth", 8))
|
|
991
|
+
var digest := {}
|
|
992
|
+
var count := _digest_walk(root, 0, max_depth, fields, digest)
|
|
993
|
+
return _ok({"digest": digest, "node_count": count})
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
func _digest_walk(node: Node, depth: int, max_depth: int, fields: Array, digest: Dictionary) -> int:
|
|
997
|
+
var entry := {}
|
|
998
|
+
if fields.is_empty():
|
|
999
|
+
for f in _DIGEST_DEFAULT_FIELDS:
|
|
1000
|
+
var dv: Variant = node.get(f)
|
|
1001
|
+
if dv != null:
|
|
1002
|
+
entry[f] = Codec.encode(dv)
|
|
1003
|
+
else:
|
|
1004
|
+
for f in fields:
|
|
1005
|
+
var fn := String(f)
|
|
1006
|
+
entry[fn] = Codec.encode(node.get(fn))
|
|
1007
|
+
digest[_path_of(node)] = entry
|
|
1008
|
+
var count := 1
|
|
1009
|
+
if depth < max_depth:
|
|
1010
|
+
for ch in node.get_children():
|
|
1011
|
+
count += _digest_walk(ch, depth + 1, max_depth, fields, digest)
|
|
1012
|
+
return count
|
package/dist/cli/doctor.js
CHANGED
|
@@ -193,7 +193,7 @@ export function checkCapabilities(config) {
|
|
|
193
193
|
status: "ok",
|
|
194
194
|
severity: "info",
|
|
195
195
|
detail: dropped.length === 0
|
|
196
|
-
? `${state} — full
|
|
196
|
+
? `${state} — full 286-tool surface`
|
|
197
197
|
: `${state} (secure default) — ${dropped.length} higher-trust tool(s) dropped from the surface`,
|
|
198
198
|
hint: dropped.length === 0
|
|
199
199
|
? 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 286-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/index.js
CHANGED
|
@@ -35,7 +35,7 @@ async function main() {
|
|
|
35
35
|
// so long jobs (export/import/headless script) support poll/await/cancel.
|
|
36
36
|
// D3: also advertise resources.subscribe so clients can subscribe to
|
|
37
37
|
// godot://… resources and receive notifications/resources/updated.
|
|
38
|
-
const server = new McpServer({ name: "breakpoint-mcp", version: "1.
|
|
38
|
+
const server = new McpServer({ name: "breakpoint-mcp", version: "1.21.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
|
|
39
39
|
// B1: enforce frozen output schemas on every structured tool. Must run before
|
|
40
40
|
// any register*Tools call — it wraps server.registerTool.
|
|
41
41
|
applyOutputSchemas(server);
|
package/dist/recipes.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* it's **free** (MIT, shipped in the server), and it sits **over typed,
|
|
8
8
|
* schema-validated, undoable tools** — so the contract is executed by the
|
|
9
9
|
* server, not merely described in prose the model might misapply. Recipes are
|
|
10
|
-
* discoverable via MCP `prompts/list`; they add **no tools** (the
|
|
10
|
+
* discoverable via MCP `prompts/list`; they add **no tools** (the 286-tool
|
|
11
11
|
* count is unchanged) and cost nothing until a client pulls one.
|
|
12
12
|
*/
|
|
13
13
|
import { z } from "zod";
|
|
@@ -27,6 +27,7 @@ export const RECIPE_NAMES = [
|
|
|
27
27
|
"recipe_screenshot_regression",
|
|
28
28
|
"recipe_type_safe_edit",
|
|
29
29
|
"recipe_csharp_fix_and_debug",
|
|
30
|
+
"recipe_deterministic_playtest",
|
|
30
31
|
];
|
|
31
32
|
export function registerRecipes(server) {
|
|
32
33
|
// 1 — 2D player controller, then verify it moves.
|
|
@@ -164,5 +165,36 @@ export function registerRecipes(server) {
|
|
|
164
165
|
SAFETY,
|
|
165
166
|
].join("\n"));
|
|
166
167
|
});
|
|
168
|
+
// 7 — Deterministic playtesting: freeze, step exact frames, assert a golden frame.
|
|
169
|
+
server.registerPrompt("recipe_deterministic_playtest", {
|
|
170
|
+
title: "Freeze the game, step exact frames, and assert a deterministic golden frame",
|
|
171
|
+
description: "Turn a flaky run-screenshot-guess loop into a frame-exact, reproducible one: freeze time, advance a fixed number of frames, then assert node state and diff a golden frame.",
|
|
172
|
+
argsSchema: {
|
|
173
|
+
scene_path: z.string().optional().describe("Scene to play-test (default res://main.tscn)"),
|
|
174
|
+
root_path: z.string().optional().describe("Node to snapshot/assert on (default '.', the running scene root)"),
|
|
175
|
+
frames: z.string().optional().describe("Frames to advance per step (default 30)"),
|
|
176
|
+
reference_path: z.string().optional().describe("Golden PNG for the screenshot diff (default res://tests/frame.png)"),
|
|
177
|
+
tolerance: z.string().optional().describe("Max fraction of differing pixels for the diff (default 0.0)"),
|
|
178
|
+
},
|
|
179
|
+
}, (a) => {
|
|
180
|
+
const scene = str(a, "scene_path", "res://main.tscn");
|
|
181
|
+
const root = str(a, "root_path", ".");
|
|
182
|
+
const frames = str(a, "frames", "30");
|
|
183
|
+
const ref = str(a, "reference_path", "res://tests/frame.png");
|
|
184
|
+
const tol = str(a, "tolerance", "0.0");
|
|
185
|
+
return recipe("Deterministic, frame-exact playtesting with a golden-frame check.", [
|
|
186
|
+
`Goal: drive ${scene} to an exact, reproducible frame and assert on it — no wall-clock flakiness.`,
|
|
187
|
+
"",
|
|
188
|
+
`1. Boot + freeze: godot_run_project. runtime_time_scale{scale:0} to FREEZE the game (this pauses the SceneTree, so nothing advances on its own). If the run uses randomness, runtime_seed_rng{seed:...} first so the whole playtest is reproducible.`,
|
|
189
|
+
`2. Baseline: runtime_state_digest{root:"${root}"} to snapshot the frozen state (positions / visibility / modulate, stable-ordered) — this is your deterministic "before".`,
|
|
190
|
+
`3. Step: runtime_step_frames{frames:${frames}, kind:"physics"} to advance EXACTLY ${frames} frames while frozen (use kind:"both" to also tick _process each step). The game stays frozen afterwards, so your reads are stable.`,
|
|
191
|
+
`4. Assert: runtime_state_digest{root:"${root}"} (or runtime_assert_node_state) that the node reached the expected, frame-exact state, then runtime_screenshot_diff against ${ref} with tolerance ${tol} — because you stepped a fixed number of frames the frame is reproducible, so the diff is stable (establish ${ref} once with runtime_screenshot on the first green run, then vcs_add it).`,
|
|
192
|
+
`5. Iterate or thaw: repeat 3–4 to walk frame-by-frame (every step is deterministic), or runtime_time_scale{scale:1} to thaw and resume normal play. godot_stop when done. On a failed assert you are one dbg_set_breakpoints + dbg_launch away from the exact value that diverged.`,
|
|
193
|
+
"",
|
|
194
|
+
"Why this beats run-screenshot-guess: freezing plus fixed-frame stepping converts a timing-dependent check into a frame-exact, reproducible one the runtime_assert_* family can verify — the R tier of the W/R/E model, made deterministic.",
|
|
195
|
+
"",
|
|
196
|
+
SAFETY,
|
|
197
|
+
].join("\n"));
|
|
198
|
+
});
|
|
167
199
|
}
|
|
168
200
|
//# sourceMappingURL=recipes.js.map
|
package/dist/schemas.js
CHANGED
|
@@ -494,6 +494,25 @@ export const outputSchemas = {
|
|
|
494
494
|
reference: z.string(),
|
|
495
495
|
reason: z.string().optional(),
|
|
496
496
|
},
|
|
497
|
+
// F8 additions: await/animation/node-lifecycle over the runtime bridge.
|
|
498
|
+
runtime_await_condition: { met: z.boolean(), polls: z.number(), elapsed_ms: z.number(), value: encodedValue },
|
|
499
|
+
runtime_anim_play: { playing: z.boolean(), current_animation: z.string(), speed_scale: z.number() },
|
|
500
|
+
runtime_anim_stop: { playing: z.boolean(), current_animation: z.string(), position: z.number() },
|
|
501
|
+
runtime_anim_get_state: {
|
|
502
|
+
playing: z.boolean(),
|
|
503
|
+
current_animation: z.string(),
|
|
504
|
+
position: z.number(),
|
|
505
|
+
length: z.number(),
|
|
506
|
+
speed_scale: z.number(),
|
|
507
|
+
animations: z.array(z.string()),
|
|
508
|
+
},
|
|
509
|
+
runtime_node_add: { added: z.boolean(), path: z.string(), type: z.string() },
|
|
510
|
+
runtime_node_remove: { removed: z.boolean(), path: z.string() },
|
|
511
|
+
// F4 additions: deterministic playtesting (time control / frame stepping / state digest / RNG seed).
|
|
512
|
+
runtime_time_scale: { previous: z.number(), current: z.number() },
|
|
513
|
+
runtime_step_frames: { frames_advanced: z.number(), frame_index: z.number() },
|
|
514
|
+
runtime_state_digest: { digest: z.record(z.record(encodedValue)), node_count: z.number() },
|
|
515
|
+
runtime_seed_rng: { seed: z.number() },
|
|
497
516
|
// ---- Group K: knowledge & search ----
|
|
498
517
|
// Host-side project index (tools/knowledge.ts) — no bridge/LSP; read the project files directly.
|
|
499
518
|
project_search: {
|
package/dist/tools/runtime.js
CHANGED
|
@@ -15,6 +15,31 @@ function fail(err) {
|
|
|
15
15
|
content: [{ type: "text", text: `Runtime error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}` }],
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Compare a polled property value against an expected value under one of the
|
|
20
|
+
* restricted operators exposed by runtime_await_condition. eq/ne use structural
|
|
21
|
+
* (JSON) equality so tagged-Variant objects compare cleanly; the ordered
|
|
22
|
+
* operators are numeric-only and are false unless both sides are numbers.
|
|
23
|
+
*/
|
|
24
|
+
function compareValues(actual, expected, op) {
|
|
25
|
+
const bothNum = typeof actual === "number" && typeof expected === "number";
|
|
26
|
+
switch (op) {
|
|
27
|
+
case "ne":
|
|
28
|
+
return JSON.stringify(actual) !== JSON.stringify(expected);
|
|
29
|
+
case "gt":
|
|
30
|
+
return bothNum && actual > expected;
|
|
31
|
+
case "ge":
|
|
32
|
+
return bothNum && actual >= expected;
|
|
33
|
+
case "lt":
|
|
34
|
+
return bothNum && actual < expected;
|
|
35
|
+
case "le":
|
|
36
|
+
return bothNum && actual <= expected;
|
|
37
|
+
case "eq":
|
|
38
|
+
default:
|
|
39
|
+
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
43
|
export function registerRuntimeTools(server, runtime) {
|
|
19
44
|
const call = async (method, params = {}) => {
|
|
20
45
|
try {
|
|
@@ -233,5 +258,186 @@ export function registerRuntimeTools(server, runtime) {
|
|
|
233
258
|
...(per_channel_threshold !== undefined ? { per_channel_threshold } : {}),
|
|
234
259
|
...(region !== undefined ? { region } : {}),
|
|
235
260
|
}));
|
|
261
|
+
// F8: deterministic-verification helper. Poll a live property until it meets a
|
|
262
|
+
// comparison, or a timeout elapses. Host-side over runtime.get_property (no new
|
|
263
|
+
// bridge method), so it works on every engine build the runtime bridge supports.
|
|
264
|
+
// Read-only: it never mutates the game, so it is not confirmation-gated.
|
|
265
|
+
server.registerTool("runtime_await_condition", {
|
|
266
|
+
title: "Runtime await condition",
|
|
267
|
+
description: "Poll a property on a LIVE node until it satisfies a comparison, or a timeout elapses (read-only verification). " +
|
|
268
|
+
"op is eq | ne | gt | ge | lt | le (the ordered operators require numeric values). Use this to wait for the " +
|
|
269
|
+
"running game to reach a state before asserting on it — e.g. wait for hp le 0, then runtime_assert_screen_text.",
|
|
270
|
+
inputSchema: {
|
|
271
|
+
path: z.string().describe("Node path (relative to the current scene; '/root/...' absolute allowed)"),
|
|
272
|
+
property: z.string().describe("Property to read on each poll"),
|
|
273
|
+
value: z.any().describe("Value to compare the property against (tagged-Variant form for complex types)"),
|
|
274
|
+
op: z.enum(["eq", "ne", "gt", "ge", "lt", "le"]).optional().describe("Comparison operator (default eq)"),
|
|
275
|
+
timeout_ms: z.number().int().positive().optional().describe("Maximum time to wait, in ms (default 5000)"),
|
|
276
|
+
poll_interval_ms: z.number().int().positive().optional().describe("Delay between polls, in ms (default 100)"),
|
|
277
|
+
},
|
|
278
|
+
}, async ({ path, property, value, op, timeout_ms, poll_interval_ms }) => {
|
|
279
|
+
const operator = op ?? "eq";
|
|
280
|
+
const interval = poll_interval_ms ?? 100;
|
|
281
|
+
const start = Date.now();
|
|
282
|
+
const deadline = start + (timeout_ms ?? 5000);
|
|
283
|
+
let polls = 0;
|
|
284
|
+
let last = null;
|
|
285
|
+
for (;;) {
|
|
286
|
+
polls++;
|
|
287
|
+
let res;
|
|
288
|
+
try {
|
|
289
|
+
res = (await runtime.request("runtime.get_property", { path, property }));
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return fail(err);
|
|
293
|
+
}
|
|
294
|
+
last = res?.value ?? null;
|
|
295
|
+
if (compareValues(last, value, operator)) {
|
|
296
|
+
return ok({ met: true, polls, elapsed_ms: Date.now() - start, value: last });
|
|
297
|
+
}
|
|
298
|
+
if (Date.now() >= deadline) {
|
|
299
|
+
return ok({ met: false, polls, elapsed_ms: Date.now() - start, value: last });
|
|
300
|
+
}
|
|
301
|
+
await sleep(interval);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
server.registerTool("runtime_anim_play", {
|
|
305
|
+
title: "Runtime play animation",
|
|
306
|
+
description: "Play an animation on a LIVE AnimationPlayer node. DESTRUCTIVE (drives the running game) — gated by confirmation. " +
|
|
307
|
+
"Omit `animation` to (re)play the currently-assigned one.",
|
|
308
|
+
inputSchema: {
|
|
309
|
+
path: z.string().describe("Path to an AnimationPlayer node in the running scene"),
|
|
310
|
+
animation: z.string().optional().describe("Animation name to play (default: the current/assigned animation)"),
|
|
311
|
+
custom_speed: z.number().optional().describe("Playback speed multiplier (default 1.0; negative not supported here)"),
|
|
312
|
+
from_end: z.boolean().optional().describe("Start playback from the end (default false)"),
|
|
313
|
+
...confirmField,
|
|
314
|
+
},
|
|
315
|
+
}, async ({ path, animation, custom_speed, from_end, confirm }) => {
|
|
316
|
+
const blocked = await gate(server, confirm, `Play animation "${animation ?? "(current)"}" on ${path}`);
|
|
317
|
+
if (blocked)
|
|
318
|
+
return blocked;
|
|
319
|
+
return call("runtime.anim_play", {
|
|
320
|
+
path,
|
|
321
|
+
...(animation !== undefined ? { animation } : {}),
|
|
322
|
+
...(custom_speed !== undefined ? { custom_speed } : {}),
|
|
323
|
+
...(from_end !== undefined ? { from_end } : {}),
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
server.registerTool("runtime_anim_stop", {
|
|
327
|
+
title: "Runtime stop animation",
|
|
328
|
+
description: "Stop (or pause) a LIVE AnimationPlayer node. DESTRUCTIVE (drives the running game) — gated by confirmation. " +
|
|
329
|
+
"keep_state:true pauses in place; false (default) stops.",
|
|
330
|
+
inputSchema: {
|
|
331
|
+
path: z.string().describe("Path to an AnimationPlayer node in the running scene"),
|
|
332
|
+
keep_state: z.boolean().optional().describe("Pause in place instead of stopping (default false)"),
|
|
333
|
+
...confirmField,
|
|
334
|
+
},
|
|
335
|
+
}, async ({ path, keep_state, confirm }) => {
|
|
336
|
+
const blocked = await gate(server, confirm, `Stop animation on ${path}`);
|
|
337
|
+
if (blocked)
|
|
338
|
+
return blocked;
|
|
339
|
+
return call("runtime.anim_stop", { path, ...(keep_state !== undefined ? { keep_state } : {}) });
|
|
340
|
+
});
|
|
341
|
+
server.registerTool("runtime_anim_get_state", {
|
|
342
|
+
title: "Runtime animation state",
|
|
343
|
+
description: "Read the playback state of a LIVE AnimationPlayer (read-only): current animation, whether it is playing, " +
|
|
344
|
+
"position, length, speed scale, and the list of available animations.",
|
|
345
|
+
inputSchema: { path: z.string().describe("Path to an AnimationPlayer node in the running scene") },
|
|
346
|
+
}, async ({ path }) => call("runtime.anim_get_state", { path }));
|
|
347
|
+
server.registerTool("runtime_node_add", {
|
|
348
|
+
title: "Runtime add node",
|
|
349
|
+
description: "Add a node to the LIVE running game as a child of `parent`. DESTRUCTIVE — gated by confirmation. " +
|
|
350
|
+
"Provide `scene` (a res:// PackedScene to instantiate) OR `type` (a ClassDB class to instantiate); `name` renames it.",
|
|
351
|
+
inputSchema: {
|
|
352
|
+
parent: z.string().describe("Path to the parent node in the running scene"),
|
|
353
|
+
type: z.string().optional().describe("Class name to instantiate (e.g. Node2D) — mutually exclusive with `scene`"),
|
|
354
|
+
scene: z.string().optional().describe("res:// path to a PackedScene to instantiate — mutually exclusive with `type`"),
|
|
355
|
+
name: z.string().optional().describe("Optional name for the new node"),
|
|
356
|
+
...confirmField,
|
|
357
|
+
},
|
|
358
|
+
}, async ({ parent, type, scene, name, confirm }) => {
|
|
359
|
+
const blocked = await gate(server, confirm, `Add ${scene ?? type ?? "node"} under ${parent} in the running game`);
|
|
360
|
+
if (blocked)
|
|
361
|
+
return blocked;
|
|
362
|
+
return call("runtime.node_add", {
|
|
363
|
+
parent,
|
|
364
|
+
...(type !== undefined ? { type } : {}),
|
|
365
|
+
...(scene !== undefined ? { scene } : {}),
|
|
366
|
+
...(name !== undefined ? { name } : {}),
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
server.registerTool("runtime_node_remove", {
|
|
370
|
+
title: "Runtime remove node",
|
|
371
|
+
description: "Remove (queue_free) a node from the LIVE running game. DESTRUCTIVE — gated by confirmation. " +
|
|
372
|
+
"Refuses to remove the current scene root.",
|
|
373
|
+
inputSchema: { path: z.string().describe("Path to the node to remove in the running scene"), ...confirmField },
|
|
374
|
+
}, async ({ path, confirm }) => {
|
|
375
|
+
const blocked = await gate(server, confirm, `Remove ${path} from the running game`);
|
|
376
|
+
if (blocked)
|
|
377
|
+
return blocked;
|
|
378
|
+
return call("runtime.node_remove", { path });
|
|
379
|
+
});
|
|
380
|
+
// F4: deterministic playtesting — freeze time, step exact frames, snapshot state, seed RNG.
|
|
381
|
+
server.registerTool("runtime_time_scale", {
|
|
382
|
+
title: "Runtime time scale",
|
|
383
|
+
description: "Set Engine.time_scale on the running game: 0 freezes time, 1 is normal, >1 fast, <1 slow-motion. " +
|
|
384
|
+
"DESTRUCTIVE (alters the running game's clock) — gated by confirmation. Freeze with scale 0, then " +
|
|
385
|
+
"runtime_step_frames to advance deterministically before asserting.",
|
|
386
|
+
inputSchema: {
|
|
387
|
+
scale: z.number().min(0).describe("0 = freeze, 1 = normal, N = slow/fast (negative is clamped to 0)"),
|
|
388
|
+
...confirmField,
|
|
389
|
+
},
|
|
390
|
+
}, async ({ scale, confirm }) => {
|
|
391
|
+
const blocked = await gate(server, confirm, `Set time scale to ${scale} on the running game`);
|
|
392
|
+
if (blocked)
|
|
393
|
+
return blocked;
|
|
394
|
+
return call("runtime.time_scale", { scale });
|
|
395
|
+
});
|
|
396
|
+
server.registerTool("runtime_step_frames", {
|
|
397
|
+
title: "Runtime step frames",
|
|
398
|
+
description: "Advance the running game by an exact number of frames while otherwise frozen, for deterministic, " +
|
|
399
|
+
"frame-accurate playtesting. DESTRUCTIVE — gated by confirmation. `kind` selects which loop to tick each " +
|
|
400
|
+
"step: idle (default), physics, or both. Pair with runtime_time_scale{scale:0} to freeze, then assert.",
|
|
401
|
+
inputSchema: {
|
|
402
|
+
frames: z.number().int().positive().describe("Number of frames to advance"),
|
|
403
|
+
kind: z.enum(["idle", "physics", "both"]).optional().describe("Which loop to tick each step (default idle)"),
|
|
404
|
+
...confirmField,
|
|
405
|
+
},
|
|
406
|
+
}, async ({ frames, kind, confirm }) => {
|
|
407
|
+
const blocked = await gate(server, confirm, `Advance ${frames} frame(s) of the running game`);
|
|
408
|
+
if (blocked)
|
|
409
|
+
return blocked;
|
|
410
|
+
return call("runtime.step_frames", { frames, ...(kind !== undefined ? { kind } : {}) });
|
|
411
|
+
});
|
|
412
|
+
server.registerTool("runtime_state_digest", {
|
|
413
|
+
title: "Runtime state digest",
|
|
414
|
+
description: "Capture a compact, stable-ordered JSON snapshot of a live subtree's salient state (read-only) — position, " +
|
|
415
|
+
"rotation, scale, visibility, and modulate by default, or a caller-supplied field list. Deterministic ordering " +
|
|
416
|
+
"makes it ideal for frame-by-frame comparison alongside runtime_step_frames.",
|
|
417
|
+
inputSchema: {
|
|
418
|
+
root: z.string().describe("Root node path in the running scene"),
|
|
419
|
+
fields: z
|
|
420
|
+
.array(z.string())
|
|
421
|
+
.optional()
|
|
422
|
+
.describe("Property names to capture per node (default: position/global_position/rotation/scale/visible/modulate when present)"),
|
|
423
|
+
max_depth: z.number().int().nonnegative().optional().describe("Max recursion depth (default 8)"),
|
|
424
|
+
},
|
|
425
|
+
}, async ({ root, fields, max_depth }) => call("runtime.state_digest", {
|
|
426
|
+
root,
|
|
427
|
+
...(fields !== undefined ? { fields } : {}),
|
|
428
|
+
...(max_depth !== undefined ? { max_depth } : {}),
|
|
429
|
+
}));
|
|
430
|
+
server.registerTool("runtime_seed_rng", {
|
|
431
|
+
title: "Runtime seed RNG",
|
|
432
|
+
description: "Seed the running game's GLOBAL random number generator (GDScript seed()) so a playtest is reproducible. " +
|
|
433
|
+
"DESTRUCTIVE (changes RNG state) — gated by confirmation. Note: seeds only the global RNG (randi/randf), not " +
|
|
434
|
+
"per-instance RandomNumberGenerators or physics determinism.",
|
|
435
|
+
inputSchema: { seed: z.number().int().describe("Seed value for the global RNG"), ...confirmField },
|
|
436
|
+
}, async ({ seed, confirm }) => {
|
|
437
|
+
const blocked = await gate(server, confirm, `Seed the running game's global RNG with ${seed}`);
|
|
438
|
+
if (blocked)
|
|
439
|
+
return blocked;
|
|
440
|
+
return call("runtime.seed_rng", { seed });
|
|
441
|
+
});
|
|
236
442
|
}
|
|
237
443
|
//# sourceMappingURL=runtime.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakpoint-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.1",
|
|
4
4
|
"description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol — developed and tested with Claude — across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|