@satelliteoflove/godot-mcp 3.4.1 → 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
@@ -2,7 +2,8 @@
2
2
  extends MCPBaseCommand
3
3
  class_name MCPScreenshotCommands
4
4
 
5
- const DEFAULT_MAX_WIDTH := 1920
5
+ const DEFAULT_MAX_WIDTH := 1024
6
+ const DEFAULT_JPEG_QUALITY := 75
6
7
  const SCREENSHOT_TIMEOUT := 5.0
7
8
 
8
9
  var _screenshot_result: Dictionary = {}
@@ -21,6 +22,7 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
21
22
  return _error("NOT_RUNNING", "No game is currently running. Use run_project first.")
22
23
 
23
24
  var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
25
+ var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
24
26
 
25
27
  var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
26
28
  if debugger_plugin == null:
@@ -33,7 +35,7 @@ func capture_game_screenshot(params: Dictionary) -> Dictionary:
33
35
  _screenshot_result = {}
34
36
 
35
37
  debugger_plugin.screenshot_received.connect(_on_screenshot_received, CONNECT_ONE_SHOT)
36
- debugger_plugin.request_screenshot(max_width)
38
+ debugger_plugin.request_screenshot(max_width, quality)
37
39
 
38
40
  var start_time := Time.get_ticks_msec()
39
41
  while _screenshot_pending:
@@ -62,6 +64,7 @@ func _on_screenshot_received(success: bool, image_base64: String, width: int, he
62
64
  func capture_editor_screenshot(params: Dictionary) -> Dictionary:
63
65
  var viewport_type: String = params.get("viewport", "")
64
66
  var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
67
+ var quality: float = params.get("quality", DEFAULT_JPEG_QUALITY) / 100.0
65
68
 
66
69
  var viewport: SubViewport = null
67
70
 
@@ -76,10 +79,10 @@ func capture_editor_screenshot(params: Dictionary) -> Dictionary:
76
79
  return _error("NO_VIEWPORT", "Could not find editor viewport")
77
80
 
78
81
  var image := viewport.get_texture().get_image()
79
- return _process_and_encode_image(image, max_width)
82
+ return _process_and_encode_image(image, max_width, quality)
80
83
 
81
84
 
82
- func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
85
+ func _process_and_encode_image(image: Image, max_width: int, quality: float = 0.75) -> Dictionary:
83
86
  if image == null:
84
87
  return _error("CAPTURE_FAILED", "Failed to capture image from viewport")
85
88
 
@@ -88,8 +91,8 @@ func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
88
91
  var new_height := int(image.get_height() * scale_factor)
89
92
  image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
90
93
 
91
- var png_buffer := image.save_png_to_buffer()
92
- var base64 := Marshalls.raw_to_base64(png_buffer)
94
+ var jpg_buffer := image.save_jpg_to_buffer(quality)
95
+ var base64 := Marshalls.raw_to_base64(jpg_buffer)
93
96
 
94
97
  return _success({
95
98
  "image_base64": base64,
@@ -97,14 +97,14 @@ func has_active_session() -> bool:
97
97
  return true
98
98
 
99
99
 
100
- func request_screenshot(max_width: int = 1920) -> void:
100
+ func request_screenshot(max_width: int = 1024, quality: float = 0.75) -> void:
101
101
  if _active_session_id < 0:
102
102
  screenshot_received.emit(false, "", 0, 0, "No active game session")
103
103
  return
104
104
  _pending_screenshot = true
105
105
  var session := get_session(_active_session_id)
106
106
  if session:
107
- session.send_message("godot_mcp:take_screenshot", [max_width])
107
+ session.send_message("godot_mcp:take_screenshot", [max_width, quality])
108
108
  else:
109
109
  _pending_screenshot = false
110
110
  screenshot_received.emit(false, "", 0, 0, "Could not get debugger session")
@@ -1,10 +1,12 @@
1
1
  extends Node
2
2
  class_name MCPGameBridge
3
3
 
4
- const DEFAULT_MAX_WIDTH := 1920
4
+ const DEFAULT_MAX_WIDTH := 1024
5
+ const DEFAULT_JPEG_QUALITY := 0.75
5
6
 
6
7
  var _logger: _MCPGameLogger
7
8
  var _profiler: MCPFrameProfiler
9
+ var _sampler: MCPRuntimeStateSampler
8
10
 
9
11
 
10
12
  func _ready() -> void:
@@ -14,6 +16,8 @@ func _ready() -> void:
14
16
  OS.add_logger(_logger)
15
17
  _profiler = MCPFrameProfiler.new()
16
18
  EngineDebugger.register_profiler("mcp_frame_profiler", _profiler)
19
+ _sampler = MCPRuntimeStateSampler.new()
20
+ add_child(_sampler)
17
21
  EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
18
22
  MCPLog.info("Game bridge initialized")
19
23
 
@@ -89,16 +93,29 @@ func _on_debugger_message(message: String, data: Array) -> bool:
89
93
  "get_signal_connections":
90
94
  _handle_get_signal_connections(data)
91
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
92
108
  return false
93
109
 
94
110
 
95
111
  func _take_screenshot_deferred(data: Array) -> void:
96
112
  var max_width: int = data[0] if data.size() > 0 else DEFAULT_MAX_WIDTH
113
+ var quality: float = data[1] if data.size() > 1 else DEFAULT_JPEG_QUALITY
97
114
  await RenderingServer.frame_post_draw
98
- _capture_and_send_screenshot(max_width)
115
+ _capture_and_send_screenshot(max_width, quality)
99
116
 
100
117
 
101
- func _capture_and_send_screenshot(max_width: int) -> void:
118
+ func _capture_and_send_screenshot(max_width: int, quality: float = DEFAULT_JPEG_QUALITY) -> void:
102
119
  var viewport := get_viewport()
103
120
  if viewport == null:
104
121
  _send_screenshot_error("NO_VIEWPORT", "Could not get game viewport")
@@ -111,8 +128,8 @@ func _capture_and_send_screenshot(max_width: int) -> void:
111
128
  var scale_factor := float(max_width) / float(image.get_width())
112
129
  var new_height := int(image.get_height() * scale_factor)
113
130
  image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
114
- var png_buffer := image.save_png_to_buffer()
115
- var base64 := Marshalls.raw_to_base64(png_buffer)
131
+ var jpg_buffer := image.save_jpg_to_buffer(quality)
132
+ var base64 := Marshalls.raw_to_base64(jpg_buffer)
116
133
  EngineDebugger.send_message("godot_mcp:screenshot_result", [
117
134
  true,
118
135
  base64,
@@ -374,6 +391,290 @@ func _node_path_string(node: Node, scene_root: Node) -> String:
374
391
  return path
375
392
 
376
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
+
377
678
  class _MCPGameLogger extends Logger:
378
679
  var _output: PackedStringArray = []
379
680
  var _max_lines := 1000