breakpoint-mcp 1.19.1 → 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 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. **276 tools
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.7.0"
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.7.0"
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
@@ -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 276-tool surface`
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 276-tool surface loads.`);
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.19.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
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 276-tool
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: {
@@ -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.19.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",