@satelliteoflove/godot-mcp 3.5.0 → 3.6.0

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.
@@ -21,6 +21,7 @@ func setup(plugin: EditorPlugin) -> void:
21
21
  _register_handler(MCPScene3DCommands.new(), plugin)
22
22
  _register_handler(MCPInputCommands.new(), plugin)
23
23
  _register_handler(MCPProfilerCommands.new(), plugin)
24
+ _register_handler(MCPRuntimeStateCommands.new(), plugin)
24
25
 
25
26
 
26
27
  func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
@@ -0,0 +1,82 @@
1
+ @tool
2
+ extends MCPBaseCommand
3
+ class_name MCPRuntimeStateCommands
4
+
5
+ const GENERIC_TIMEOUT := 5.0
6
+ var _last_error: Dictionary = {}
7
+
8
+
9
+ func get_commands() -> Dictionary:
10
+ return {
11
+ "get_runtime_state": get_runtime_state,
12
+ "watch_start": watch_start,
13
+ "watch_collect": watch_collect,
14
+ "watch_stop": watch_stop,
15
+ }
16
+
17
+
18
+ func get_runtime_state(params: Dictionary) -> Dictionary:
19
+ var result = await _send_and_wait("get_runtime_state", [params])
20
+ if result == null:
21
+ return _last_error
22
+ if result is Dictionary:
23
+ return _success(result)
24
+ return _success({"data": result})
25
+
26
+
27
+ func watch_start(params: Dictionary) -> Dictionary:
28
+ var specs: Array = params.get("specs", [])
29
+ var hz: int = params.get("hz", 20)
30
+ var duration_ms: int = params.get("duration_ms", 1000)
31
+ var result = await _send_and_wait("watch_start", [specs, hz, duration_ms])
32
+ if result == null:
33
+ return _last_error
34
+ if result is Dictionary:
35
+ return _success(result)
36
+ return _success({"started": true})
37
+
38
+
39
+ func watch_collect(_params: Dictionary) -> Dictionary:
40
+ var result = await _send_and_wait("watch_collect", [])
41
+ if result == null:
42
+ return _last_error
43
+ if result is Dictionary:
44
+ return _success(result)
45
+ return _success({"data": result})
46
+
47
+
48
+ func watch_stop(_params: Dictionary) -> Dictionary:
49
+ var result = await _send_and_wait("watch_stop", [])
50
+ if result == null:
51
+ return _last_error
52
+ if result is Dictionary:
53
+ return _success(result)
54
+ return _success({"stopped": true})
55
+
56
+
57
+ func _send_and_wait(msg_type: String, args: Array = []):
58
+ if not EditorInterface.is_playing_scene():
59
+ _last_error = _error("NOT_RUNNING", "No game is currently running")
60
+ return null
61
+
62
+ var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
63
+ if debugger_plugin == null or not debugger_plugin.has_active_session():
64
+ _last_error = _error("NO_SESSION", "No active debug session")
65
+ return null
66
+
67
+ var sent: bool = debugger_plugin.send_game_message(msg_type, args)
68
+ if not sent:
69
+ _last_error = _error("SEND_FAILED", "Failed to send message to game")
70
+ return null
71
+
72
+ var start_time := Time.get_ticks_msec()
73
+ while not debugger_plugin.has_response(msg_type):
74
+ await Engine.get_main_loop().process_frame
75
+ if (Time.get_ticks_msec() - start_time) / 1000.0 > GENERIC_TIMEOUT:
76
+ debugger_plugin.clear_response(msg_type)
77
+ _last_error = _error("TIMEOUT", "Timed out waiting for %s response" % msg_type)
78
+ return null
79
+
80
+ var response = debugger_plugin.get_response(msg_type)
81
+ debugger_plugin.clear_response(msg_type)
82
+ return response
@@ -6,6 +6,7 @@ const DEFAULT_JPEG_QUALITY := 0.75
6
6
 
7
7
  var _logger: _MCPGameLogger
8
8
  var _profiler: MCPFrameProfiler
9
+ var _sampler: MCPRuntimeStateSampler
9
10
 
10
11
 
11
12
  func _ready() -> void:
@@ -15,6 +16,8 @@ func _ready() -> void:
15
16
  OS.add_logger(_logger)
16
17
  _profiler = MCPFrameProfiler.new()
17
18
  EngineDebugger.register_profiler("mcp_frame_profiler", _profiler)
19
+ _sampler = MCPRuntimeStateSampler.new()
20
+ add_child(_sampler)
18
21
  EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
19
22
  MCPLog.info("Game bridge initialized")
20
23
 
@@ -90,6 +93,18 @@ func _on_debugger_message(message: String, data: Array) -> bool:
90
93
  "get_signal_connections":
91
94
  _handle_get_signal_connections(data)
92
95
  return true
96
+ "get_runtime_state":
97
+ _handle_get_runtime_state(data)
98
+ return true
99
+ "watch_start":
100
+ _handle_watch_start(data)
101
+ return true
102
+ "watch_collect":
103
+ _handle_watch_collect()
104
+ return true
105
+ "watch_stop":
106
+ _handle_watch_stop()
107
+ return true
93
108
  return false
94
109
 
95
110
 
@@ -376,6 +391,290 @@ func _node_path_string(node: Node, scene_root: Node) -> String:
376
391
  return path
377
392
 
378
393
 
394
+ func _handle_get_runtime_state(data: Array) -> void:
395
+ var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
396
+
397
+ var tree := get_tree()
398
+ var scene_root := tree.current_scene if tree else null
399
+ if not scene_root:
400
+ EngineDebugger.send_message("godot_mcp:game_response", ["get_runtime_state", {
401
+ "scene": "",
402
+ "selection": "fallback",
403
+ "entity_count": 0,
404
+ "entities": [],
405
+ "hint": "No scene is currently running.",
406
+ }])
407
+ return
408
+
409
+ var select_mode: String = params.get("select", "auto")
410
+ var group_name: String = params.get("group", "mcp_watch")
411
+ var name_filter: String = params.get("name", "")
412
+ var type_filter: String = params.get("type", "")
413
+ var max_nodes: int = params.get("max_nodes", 40)
414
+ var include_fields: Array = params.get("include", [])
415
+ max_nodes = clampi(max_nodes, 1, 200)
416
+
417
+ # Resolve camera for on-screen checks
418
+ var camera_2d: Camera2D = _find_camera_2d()
419
+ var camera_viewport_rect := Rect2()
420
+ if camera_2d:
421
+ camera_viewport_rect = camera_2d.get_viewport_rect()
422
+
423
+ # Determine which selection tier to use
424
+ var actual_selection: String = select_mode
425
+ if select_mode == "auto":
426
+ if _has_group_members(scene_root, group_name):
427
+ actual_selection = "group"
428
+ elif _has_mcp_state_nodes(scene_root):
429
+ actual_selection = "method"
430
+ else:
431
+ actual_selection = "fallback"
432
+
433
+ # Collect entities
434
+ var entities: Array = []
435
+ _collect_runtime_state(scene_root, scene_root, actual_selection, group_name,
436
+ name_filter, type_filter, include_fields, camera_2d, camera_viewport_rect,
437
+ max_nodes, entities)
438
+
439
+ # Extract camera entity separately if present
440
+ var camera_entity = null
441
+ var cam_node := _find_camera_2d() if actual_selection != "fallback" else camera_2d
442
+ if cam_node:
443
+ camera_entity = {
444
+ "type": "Camera2D",
445
+ "pos": {"x": snapped(cam_node.global_position.x, 0.01), "y": snapped(cam_node.global_position.y, 0.01)},
446
+ "zoom": {"x": snapped(cam_node.zoom.x, 0.01), "y": snapped(cam_node.zoom.y, 0.01)},
447
+ "camera": true,
448
+ }
449
+
450
+ var hint := ""
451
+ if actual_selection == "fallback":
452
+ hint = ("No nodes found in group '%s' and no _mcp_state() methods detected. " +
453
+ "For richer data: add key nodes to the '%s' group, then implement " +
454
+ "`func _mcp_state() -> Dictionary` on them. " +
455
+ "In _mcp_state(), include both live runtime values (position, health, score) " +
456
+ "AND static definition context (puzzle clues, level config, item data) — " +
457
+ "an agent needs both to understand and verify game state.") % [group_name, group_name]
458
+
459
+ var result: Dictionary = {
460
+ "scene": scene_root.scene_file_path,
461
+ "selection": actual_selection,
462
+ "entity_count": entities.size(),
463
+ "entities": entities,
464
+ }
465
+ if camera_entity:
466
+ result["camera"] = camera_entity
467
+ if not hint.is_empty():
468
+ result["hint"] = hint
469
+
470
+ EngineDebugger.send_message("godot_mcp:game_response", ["get_runtime_state", result])
471
+
472
+
473
+ func _has_group_members(scene_root: Node, group_name: String) -> bool:
474
+ var tree := get_tree()
475
+ if tree == null:
476
+ return false
477
+ return tree.get_nodes_in_group(group_name).size() > 0
478
+
479
+
480
+ func _has_mcp_state_nodes(node: Node) -> bool:
481
+ if node.has_method("_mcp_state"):
482
+ return true
483
+ for child in node.get_children():
484
+ if _has_mcp_state_nodes(child):
485
+ return true
486
+ return false
487
+
488
+
489
+ func _collect_runtime_state(node: Node, scene_root: Node, selection: String, group_name: String,
490
+ name_filter: String, type_filter: String, include_fields: Array,
491
+ camera_2d: Camera2D, viewport_rect: Rect2,
492
+ max_nodes: int, results: Array) -> void:
493
+ if results.size() >= max_nodes:
494
+ return
495
+
496
+ var include_node := false
497
+ match selection:
498
+ "group":
499
+ include_node = node.is_in_group(group_name)
500
+ "method":
501
+ include_node = node.has_method("_mcp_state")
502
+ "fallback":
503
+ include_node = (node is CanvasItem and (node as CanvasItem).is_visible_in_tree())
504
+
505
+ if include_node:
506
+ if not name_filter.is_empty() and not node.name.matchn(name_filter):
507
+ include_node = false
508
+ if not type_filter.is_empty() and not node.is_class(type_filter):
509
+ include_node = false
510
+
511
+ if include_node:
512
+ var entity := _extract_node_state(node, scene_root, include_fields, camera_2d, viewport_rect)
513
+ if entity != null:
514
+ results.append(entity)
515
+
516
+ for child in node.get_children():
517
+ if results.size() >= max_nodes:
518
+ return
519
+ _collect_runtime_state(child, scene_root, selection, group_name,
520
+ name_filter, type_filter, include_fields, camera_2d, viewport_rect,
521
+ max_nodes, results)
522
+
523
+
524
+ # _mcp_state() contract: return a Dictionary with two categories —
525
+ # (1) live runtime values that change during play (cursor pos, health, score, fill counts)
526
+ # (2) static definition context needed to interpret them (puzzle clues, level layout, config)
527
+ # An agent can observe (1) without (2) but cannot verify correctness without both.
528
+ # Optionally include layout geometry (bounds, sizes) to enable programmatic layout checks.
529
+ # Error handling: _mcp_state() runtime errors are non-fatal in GDScript (Godot prints them
530
+ # and the call returns null); the `is Dictionary` check below handles that silently.
531
+ func _extract_node_state(node: Node, scene_root: Node, include_fields: Array,
532
+ camera_2d: Camera2D, viewport_rect: Rect2) -> Dictionary:
533
+ var want := include_fields.is_empty()
534
+ var want_transform := want or include_fields.has("transform")
535
+ var want_velocity := want or include_fields.has("velocity")
536
+ var want_anim := want or include_fields.has("anim")
537
+ var want_groups := want or include_fields.has("groups")
538
+ var want_onscreen := want or include_fields.has("onscreen")
539
+ var want_state := want or include_fields.has("state")
540
+
541
+ var entity: Dictionary = {
542
+ "path": _node_path_string(node, scene_root),
543
+ "type": node.get_class(),
544
+ }
545
+
546
+ if want_groups:
547
+ var groups := node.get_groups().filter(func(g): return not g.begins_with("_"))
548
+ if not groups.is_empty():
549
+ entity["groups"] = groups
550
+
551
+ if want_transform and node is Node2D:
552
+ var n2d := node as Node2D
553
+ entity["pos"] = {"x": snapped(n2d.global_position.x, 0.01), "y": snapped(n2d.global_position.y, 0.01)}
554
+ entity["rot"] = snapped(rad_to_deg(n2d.global_rotation), 0.01)
555
+ if n2d.scale != Vector2.ONE:
556
+ entity["scale"] = {"x": snapped(n2d.scale.x, 0.01), "y": snapped(n2d.scale.y, 0.01)}
557
+
558
+ if want_transform and node is Node3D:
559
+ var n3d := node as Node3D
560
+ entity["pos"] = {
561
+ "x": snapped(n3d.global_position.x, 0.01),
562
+ "y": snapped(n3d.global_position.y, 0.01),
563
+ "z": snapped(n3d.global_position.z, 0.01),
564
+ }
565
+ entity["rot"] = {
566
+ "x": snapped(rad_to_deg(n3d.global_rotation.x), 0.01),
567
+ "y": snapped(rad_to_deg(n3d.global_rotation.y), 0.01),
568
+ "z": snapped(rad_to_deg(n3d.global_rotation.z), 0.01),
569
+ }
570
+
571
+ if want_velocity:
572
+ if node is CharacterBody2D:
573
+ var v := (node as CharacterBody2D).velocity
574
+ entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01)}
575
+ elif node is RigidBody2D:
576
+ var v := (node as RigidBody2D).linear_velocity
577
+ entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01)}
578
+ entity["angvel"] = snapped((node as RigidBody2D).angular_velocity, 0.01)
579
+ elif node is CharacterBody3D:
580
+ var v := (node as CharacterBody3D).velocity
581
+ entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01), "z": snapped(v.z, 0.01)}
582
+ elif node is RigidBody3D:
583
+ var v := (node as RigidBody3D).linear_velocity
584
+ entity["vel"] = {"x": snapped(v.x, 0.01), "y": snapped(v.y, 0.01), "z": snapped(v.z, 0.01)}
585
+ var av := (node as RigidBody3D).angular_velocity
586
+ entity["angvel"] = {"x": snapped(av.x, 0.01), "y": snapped(av.y, 0.01), "z": snapped(av.z, 0.01)}
587
+
588
+ if want_anim:
589
+ if node is AnimationPlayer:
590
+ var ap := node as AnimationPlayer
591
+ entity["anim"] = ap.current_animation
592
+ entity["anim_pos"] = snapped(ap.current_animation_position, 0.01)
593
+ entity["playing"] = ap.is_playing()
594
+ elif node is AnimatedSprite2D:
595
+ var asp := node as AnimatedSprite2D
596
+ entity["anim"] = asp.animation
597
+ entity["anim_frame"] = asp.frame
598
+
599
+ if want_onscreen and camera_2d and node is Node2D:
600
+ var pos := (node as Node2D).global_position
601
+ entity["onscreen"] = viewport_rect.has_point(pos)
602
+
603
+ if want_state and node.has_method("_mcp_state"):
604
+ var raw_state = node._mcp_state()
605
+ if raw_state is Dictionary:
606
+ var serialized := _serialize_mcp_state(raw_state)
607
+ if not serialized.is_empty():
608
+ entity["state"] = serialized
609
+
610
+ return entity
611
+
612
+
613
+ const _MCP_STATE_MAX_BYTES := 1024
614
+
615
+
616
+ func _serialize_mcp_state(state: Dictionary) -> Dictionary:
617
+ var result: Dictionary = {}
618
+ for key in state:
619
+ var val = state[key]
620
+ var serializable = null
621
+ match typeof(val):
622
+ TYPE_BOOL, TYPE_STRING:
623
+ serializable = val
624
+ TYPE_INT:
625
+ serializable = int(val)
626
+ TYPE_FLOAT:
627
+ serializable = snapped(float(val), 0.01)
628
+ TYPE_ARRAY:
629
+ serializable = val
630
+ TYPE_DICTIONARY:
631
+ serializable = val
632
+ # skip non-serializable types (Objects, NodePaths, RIDs, etc.)
633
+ if serializable == null:
634
+ continue
635
+ result[str(key)] = serializable
636
+ if JSON.stringify(result).length() > _MCP_STATE_MAX_BYTES:
637
+ result.erase(str(key))
638
+ result["_truncated"] = true
639
+ break
640
+ return result
641
+
642
+
643
+ func _find_camera_2d() -> Camera2D:
644
+ var viewport := get_viewport()
645
+ if viewport == null:
646
+ return null
647
+ return viewport.get_camera_2d()
648
+
649
+
650
+ func _handle_watch_start(data: Array) -> void:
651
+ if _sampler == null:
652
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {"started": false, "error": "Sampler not initialized"}])
653
+ return
654
+ var specs: Array = data[0] if data.size() > 0 else []
655
+ var hz: int = data[1] if data.size() > 1 else 20
656
+ var duration_ms: int = data[2] if data.size() > 2 else 1000
657
+ var start_result := _sampler.start(specs, hz, duration_ms)
658
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_start", {
659
+ "started": true,
660
+ "resolved_fields": start_result.get("resolved_fields", 0),
661
+ }])
662
+
663
+
664
+ func _handle_watch_collect() -> void:
665
+ if _sampler == null:
666
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", {"window_ms": 0, "sample_count": 0, "fields": {}}])
667
+ return
668
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_collect", _sampler.collect()])
669
+
670
+
671
+ func _handle_watch_stop() -> void:
672
+ if _sampler == null:
673
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", {"window_ms": 0, "sample_count": 0, "fields": {}}])
674
+ return
675
+ EngineDebugger.send_message("godot_mcp:game_response", ["watch_stop", _sampler.stop()])
676
+
677
+
379
678
  class _MCPGameLogger extends Logger:
380
679
  var _output: PackedStringArray = []
381
680
  var _max_lines := 1000
@@ -0,0 +1,215 @@
1
+ extends Node
2
+ class_name MCPRuntimeStateSampler
3
+
4
+ const MAX_FIELDS := 32
5
+ const MAX_SAMPLES_PER_FIELD := 200
6
+
7
+ var _active: bool = false
8
+ var _specs: Array = [] # [{node, fields: [{key, resolver}]}]
9
+ var _hz: int = 20
10
+ var _duration_ms: int = 1000
11
+ var _start_time: int = 0
12
+ var _stop_time: int = 0 # set on auto-stop or manual stop; 0 = still running
13
+ var _frame_index: int = 0
14
+ var _sample_interval: int = 1 # sample every N frames
15
+ var _samples: Dictionary = {} # field_key -> Array of {t_ms, value}
16
+
17
+
18
+ func start(specs: Array, hz: int, duration_ms: int) -> Dictionary:
19
+ _specs = []
20
+ _samples = {}
21
+ _hz = clampi(hz, 1, 60)
22
+ _duration_ms = clampi(duration_ms, 100, 5000)
23
+ _start_time = Time.get_ticks_msec()
24
+ _frame_index = 0
25
+ _sample_interval = max(1, int(Engine.get_frames_per_second() / _hz)) if Engine.get_frames_per_second() > 0 else max(1, int(60.0 / _hz))
26
+
27
+ var field_count := 0
28
+ for spec in specs:
29
+ if field_count >= MAX_FIELDS:
30
+ break
31
+ var node_path: String = spec.get("path", "")
32
+ var fields: Array = spec.get("fields", [])
33
+ if node_path.is_empty() or fields.is_empty():
34
+ continue
35
+
36
+ var node := _resolve_node(node_path)
37
+ if node == null:
38
+ continue
39
+
40
+ var resolved_fields: Array = []
41
+ for field_key in fields:
42
+ if field_count >= MAX_FIELDS:
43
+ break
44
+ var full_key: String = node_path + ":" + str(field_key)
45
+ _samples[full_key] = []
46
+ resolved_fields.append({"key": field_key, "full_key": full_key})
47
+ field_count += 1
48
+
49
+ if not resolved_fields.is_empty():
50
+ _specs.append({"node": node, "node_path": node_path, "fields": resolved_fields})
51
+
52
+ _stop_time = 0
53
+ _active = true
54
+ set_process(true)
55
+ return {"resolved_fields": field_count}
56
+
57
+
58
+ func _process(_delta: float) -> void:
59
+ if not _active:
60
+ return
61
+
62
+ var elapsed := Time.get_ticks_msec() - _start_time
63
+
64
+ if elapsed >= _duration_ms:
65
+ _active = false
66
+ _stop_time = Time.get_ticks_msec()
67
+ set_process(false)
68
+ return
69
+
70
+ _frame_index += 1
71
+ if _frame_index % _sample_interval != 0:
72
+ return
73
+
74
+ for spec in _specs:
75
+ var node: Node = spec.node
76
+ if not is_instance_valid(node):
77
+ # node was freed — mark all fields and skip
78
+ for field_info in spec.fields:
79
+ var arr: Array = _samples.get(field_info.full_key, [])
80
+ if arr.size() < MAX_SAMPLES_PER_FIELD:
81
+ arr.append({"t_ms": elapsed, "value": "freed"})
82
+ continue
83
+
84
+ for field_info in spec.fields:
85
+ var value = _read_field(node, field_info.key)
86
+ if value == null:
87
+ continue
88
+ var arr: Array = _samples.get(field_info.full_key, [])
89
+ if arr.size() < MAX_SAMPLES_PER_FIELD:
90
+ arr.append({"t_ms": elapsed, "value": value})
91
+
92
+
93
+ func collect() -> Dictionary:
94
+ var elapsed: int
95
+ if _stop_time > 0:
96
+ elapsed = _stop_time - _start_time
97
+ elif _start_time > 0:
98
+ elapsed = Time.get_ticks_msec() - _start_time
99
+ else:
100
+ elapsed = 0 # never started
101
+ var total_samples := 0
102
+ for key in _samples:
103
+ total_samples += (_samples[key] as Array).size()
104
+ return {
105
+ "window_ms": elapsed,
106
+ "sample_count": total_samples,
107
+ "fields": _samples.duplicate(true),
108
+ }
109
+
110
+
111
+ func stop() -> Dictionary:
112
+ _active = false
113
+ _stop_time = Time.get_ticks_msec()
114
+ set_process(false)
115
+ return collect()
116
+
117
+
118
+ func is_active() -> bool:
119
+ return _active
120
+
121
+
122
+ func _resolve_node(path: String) -> Node:
123
+ var tree := get_tree()
124
+ if tree == null:
125
+ return null
126
+ var scene_root := tree.current_scene
127
+ if scene_root == null:
128
+ return null
129
+
130
+ if path == "/root/" + scene_root.name or path == "/":
131
+ return scene_root
132
+
133
+ if path.begins_with("/root/"):
134
+ var parts := path.split("/")
135
+ # parts[0]="", parts[1]="root", parts[2]=scene_name, parts[3+]=relative
136
+ if parts.size() >= 3 and parts[2] == scene_root.name:
137
+ if parts.size() == 3:
138
+ return scene_root
139
+ var relative := "/".join(parts.slice(3))
140
+ return scene_root.get_node_or_null(relative)
141
+
142
+ return scene_root.get_node_or_null(path)
143
+
144
+
145
+ func _read_field(node: Node, key: String) -> Variant:
146
+ match key:
147
+ "pos.x":
148
+ if node is Node2D:
149
+ return snapped((node as Node2D).global_position.x, 0.01)
150
+ if node is Node3D:
151
+ return snapped((node as Node3D).global_position.x, 0.01)
152
+ "pos.y":
153
+ if node is Node2D:
154
+ return snapped((node as Node2D).global_position.y, 0.01)
155
+ if node is Node3D:
156
+ return snapped((node as Node3D).global_position.y, 0.01)
157
+ "pos.z":
158
+ if node is Node3D:
159
+ return snapped((node as Node3D).global_position.z, 0.01)
160
+ "vel.x":
161
+ if node is CharacterBody2D:
162
+ return snapped((node as CharacterBody2D).velocity.x, 0.01)
163
+ if node is RigidBody2D:
164
+ return snapped((node as RigidBody2D).linear_velocity.x, 0.01)
165
+ if node is CharacterBody3D:
166
+ return snapped((node as CharacterBody3D).velocity.x, 0.01)
167
+ if node is RigidBody3D:
168
+ return snapped((node as RigidBody3D).linear_velocity.x, 0.01)
169
+ "vel.y":
170
+ if node is CharacterBody2D:
171
+ return snapped((node as CharacterBody2D).velocity.y, 0.01)
172
+ if node is RigidBody2D:
173
+ return snapped((node as RigidBody2D).linear_velocity.y, 0.01)
174
+ if node is CharacterBody3D:
175
+ return snapped((node as CharacterBody3D).velocity.y, 0.01)
176
+ if node is RigidBody3D:
177
+ return snapped((node as RigidBody3D).linear_velocity.y, 0.01)
178
+ "vel.z":
179
+ if node is CharacterBody3D:
180
+ return snapped((node as CharacterBody3D).velocity.z, 0.01)
181
+ if node is RigidBody3D:
182
+ return snapped((node as RigidBody3D).linear_velocity.z, 0.01)
183
+ "rot":
184
+ if node is Node2D:
185
+ return snapped(rad_to_deg((node as Node2D).global_rotation), 0.01)
186
+ if node is Node3D:
187
+ return snapped(rad_to_deg((node as Node3D).global_rotation.y), 0.01)
188
+ "anim":
189
+ if node is AnimationPlayer:
190
+ return (node as AnimationPlayer).current_animation
191
+ if node is AnimatedSprite2D:
192
+ return (node as AnimatedSprite2D).animation
193
+ "anim_frame":
194
+ if node is AnimatedSprite2D:
195
+ return (node as AnimatedSprite2D).frame
196
+
197
+ # Custom state fallback — `is Dictionary` guards against _mcp_state() errors,
198
+ # which are non-fatal in GDScript (Godot prints the error and returns null).
199
+ if node.has_method("_mcp_state"):
200
+ var state = node._mcp_state()
201
+ if state is Dictionary and state.has(key):
202
+ var val = state[key]
203
+ if val is float or val is int:
204
+ return snapped(float(val), 0.01)
205
+ return val
206
+
207
+ # Generic property fallback
208
+ if key in node:
209
+ var val = node.get(key)
210
+ if val is float or val is int:
211
+ return snapped(float(val), 0.01)
212
+ if val is String or val is bool:
213
+ return val
214
+
215
+ return null
package/addon/plugin.cfg CHANGED
@@ -3,6 +3,6 @@
3
3
  name="Godot MCP"
4
4
  description="Model Context Protocol server for AI assistant integration"
5
5
  author="godot-mcp"
6
- version="3.5.0"
6
+ version="3.6.0"
7
7
  script="plugin.gd"
8
8
  godot_version_min="4.5"
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=runtime-state.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-state.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/tools/runtime-state.test.ts"],"names":[],"mappings":""}