@baixfeng/godot-mcp-cli 1.0.11
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 +187 -0
- package/addons/godot_mcp/command_handler.gd +161 -0
- package/addons/godot_mcp/command_handler.gd.uid +1 -0
- package/addons/godot_mcp/commands/base_command_processor.gd +221 -0
- package/addons/godot_mcp/commands/base_command_processor.gd.uid +1 -0
- package/addons/godot_mcp/commands/debugger_commands.gd +221 -0
- package/addons/godot_mcp/commands/debugger_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/editor_commands.gd +237 -0
- package/addons/godot_mcp/commands/editor_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/editor_script_commands.gd +365 -0
- package/addons/godot_mcp/commands/editor_script_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/input_commands.gd +337 -0
- package/addons/godot_mcp/commands/input_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/node_commands.gd +222 -0
- package/addons/godot_mcp/commands/node_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/project_commands.gd +298 -0
- package/addons/godot_mcp/commands/project_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/scene_commands.gd +337 -0
- package/addons/godot_mcp/commands/scene_commands.gd.uid +1 -0
- package/addons/godot_mcp/commands/script_commands.gd +349 -0
- package/addons/godot_mcp/commands/script_commands.gd.uid +1 -0
- package/addons/godot_mcp/mcp_asset_commands.gd +153 -0
- package/addons/godot_mcp/mcp_asset_commands.gd.uid +1 -0
- package/addons/godot_mcp/mcp_debug_output_publisher.gd +1669 -0
- package/addons/godot_mcp/mcp_debug_output_publisher.gd.uid +1 -0
- package/addons/godot_mcp/mcp_debugger_bridge.gd +1455 -0
- package/addons/godot_mcp/mcp_debugger_bridge.gd.uid +1 -0
- package/addons/godot_mcp/mcp_enhanced_commands.gd +1083 -0
- package/addons/godot_mcp/mcp_enhanced_commands.gd.uid +1 -0
- package/addons/godot_mcp/mcp_input_handler.gd +545 -0
- package/addons/godot_mcp/mcp_input_handler.gd.uid +1 -0
- package/addons/godot_mcp/mcp_runtime_debugger_bridge.gd +464 -0
- package/addons/godot_mcp/mcp_runtime_debugger_bridge.gd.uid +1 -0
- package/addons/godot_mcp/mcp_script_resource_commands.gd +165 -0
- package/addons/godot_mcp/mcp_script_resource_commands.gd.uid +1 -0
- package/addons/godot_mcp/mcp_server.gd +260 -0
- package/addons/godot_mcp/mcp_server.gd.uid +1 -0
- package/addons/godot_mcp/plugin.cfg +7 -0
- package/addons/godot_mcp/runtime_debugger.gd +81 -0
- package/addons/godot_mcp/runtime_debugger.gd.uid +1 -0
- package/addons/godot_mcp/ui/mcp_panel.gd +94 -0
- package/addons/godot_mcp/ui/mcp_panel.gd.uid +1 -0
- package/addons/godot_mcp/ui/mcp_panel.tscn +96 -0
- package/addons/godot_mcp/utils/node_utils.gd +82 -0
- package/addons/godot_mcp/utils/node_utils.gd.uid +1 -0
- package/addons/godot_mcp/utils/resource_utils.gd +81 -0
- package/addons/godot_mcp/utils/resource_utils.gd.uid +1 -0
- package/addons/godot_mcp/utils/script_utils.gd +114 -0
- package/addons/godot_mcp/utils/script_utils.gd.uid +1 -0
- package/addons/godot_mcp/websocket_server.gd +197 -0
- package/addons/godot_mcp/websocket_server.gd.uid +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +561 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +156 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/asset_resources.d.ts +29 -0
- package/dist/resources/asset_resources.js +145 -0
- package/dist/resources/asset_resources.js.map +1 -0
- package/dist/resources/debug_resources.d.ts +11 -0
- package/dist/resources/debug_resources.js +106 -0
- package/dist/resources/debug_resources.js.map +1 -0
- package/dist/resources/debugger_resources.d.ts +62 -0
- package/dist/resources/debugger_resources.js +201 -0
- package/dist/resources/debugger_resources.js.map +1 -0
- package/dist/resources/editor_resources.d.ts +47 -0
- package/dist/resources/editor_resources.js +155 -0
- package/dist/resources/editor_resources.js.map +1 -0
- package/dist/resources/project_resources.d.ts +33 -0
- package/dist/resources/project_resources.js +137 -0
- package/dist/resources/project_resources.js.map +1 -0
- package/dist/resources/scene_resources.d.ts +33 -0
- package/dist/resources/scene_resources.js +160 -0
- package/dist/resources/scene_resources.js.map +1 -0
- package/dist/resources/script_resources.d.ts +51 -0
- package/dist/resources/script_resources.js +203 -0
- package/dist/resources/script_resources.js.map +1 -0
- package/dist/tools/asset_tools.d.ts +5 -0
- package/dist/tools/asset_tools.js +125 -0
- package/dist/tools/asset_tools.js.map +1 -0
- package/dist/tools/debugger_tools.d.ts +2 -0
- package/dist/tools/debugger_tools.js +342 -0
- package/dist/tools/debugger_tools.js.map +1 -0
- package/dist/tools/editor_tools.d.ts +2 -0
- package/dist/tools/editor_tools.js +165 -0
- package/dist/tools/editor_tools.js.map +1 -0
- package/dist/tools/enhanced_tools.d.ts +5 -0
- package/dist/tools/enhanced_tools.js +706 -0
- package/dist/tools/enhanced_tools.js.map +1 -0
- package/dist/tools/input_tools.d.ts +2 -0
- package/dist/tools/input_tools.js +408 -0
- package/dist/tools/input_tools.js.map +1 -0
- package/dist/tools/node_tools.d.ts +5 -0
- package/dist/tools/node_tools.js +217 -0
- package/dist/tools/node_tools.js.map +1 -0
- package/dist/tools/project_tools.d.ts +5 -0
- package/dist/tools/project_tools.js +162 -0
- package/dist/tools/project_tools.js.map +1 -0
- package/dist/tools/scene_tools.d.ts +5 -0
- package/dist/tools/scene_tools.js +260 -0
- package/dist/tools/scene_tools.js.map +1 -0
- package/dist/tools/script_resource_tools.d.ts +5 -0
- package/dist/tools/script_resource_tools.js +5 -0
- package/dist/tools/script_resource_tools.js.map +1 -0
- package/dist/tools/script_tools.d.ts +5 -0
- package/dist/tools/script_tools.js +154 -0
- package/dist/tools/script_tools.js.map +1 -0
- package/dist/utils/godot_connection.d.ts +30 -0
- package/dist/utils/godot_connection.js +285 -0
- package/dist/utils/godot_connection.js.map +1 -0
- package/dist/utils/types.d.ts +16 -0
- package/dist/utils/types.js +2 -0
- package/dist/utils/types.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
class_name MCPEditorScriptCommands
|
|
3
|
+
extends MCPBaseCommandProcessor
|
|
4
|
+
|
|
5
|
+
const EXECUTION_TIMEOUT_SECONDS := 1.5
|
|
6
|
+
const MAX_LOG_TAIL_CHARS := 2048
|
|
7
|
+
const MAX_LOG_TAIL_LINES := 20
|
|
8
|
+
|
|
9
|
+
var _pending_executions := {}
|
|
10
|
+
|
|
11
|
+
func process_command(client_id: int, command_type: String, params: Dictionary, command_id: String) -> bool:
|
|
12
|
+
match command_type:
|
|
13
|
+
"execute_editor_script":
|
|
14
|
+
_execute_editor_script(client_id, params, command_id)
|
|
15
|
+
return true
|
|
16
|
+
return false # Command not handled
|
|
17
|
+
|
|
18
|
+
# Add API compatibility fixing function
|
|
19
|
+
func _fix_api_compatibility(code: String) -> String:
|
|
20
|
+
var modified_code = code
|
|
21
|
+
|
|
22
|
+
# Handle Directory API (replaced with DirAccess in Godot 4.x)
|
|
23
|
+
if "Directory.new()" in modified_code:
|
|
24
|
+
modified_code = modified_code.replace("Directory.new()", "DirAccess.open('res://')")
|
|
25
|
+
modified_code = modified_code.replace("dir.list_dir_begin(true, true)", "dir.list_dir_begin()")
|
|
26
|
+
|
|
27
|
+
# Handle File API (replaced with FileAccess in Godot 4.x)
|
|
28
|
+
if "File.new()" in modified_code:
|
|
29
|
+
modified_code = modified_code.replace("File.new()", "FileAccess.open('res://', FileAccess.READ)")
|
|
30
|
+
modified_code = modified_code.replace("file.open(", "file = FileAccess.open(")
|
|
31
|
+
|
|
32
|
+
return modified_code
|
|
33
|
+
|
|
34
|
+
func _execute_editor_script(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
35
|
+
var code = params.get("code", "")
|
|
36
|
+
|
|
37
|
+
# Validation
|
|
38
|
+
if code.is_empty():
|
|
39
|
+
return _send_error(client_id, "Code cannot be empty", command_id)
|
|
40
|
+
|
|
41
|
+
# Fix common API incompatibilities
|
|
42
|
+
code = _fix_api_compatibility(code)
|
|
43
|
+
|
|
44
|
+
var parse_log_snapshot = _capture_log_snapshot()
|
|
45
|
+
|
|
46
|
+
# Create a temporary script node to execute the code
|
|
47
|
+
var script_node := Node.new()
|
|
48
|
+
script_node.name = "EditorScriptExecutor"
|
|
49
|
+
add_child(script_node)
|
|
50
|
+
|
|
51
|
+
# Create a temporary script
|
|
52
|
+
var script = GDScript.new()
|
|
53
|
+
|
|
54
|
+
var output = []
|
|
55
|
+
var error_message = ""
|
|
56
|
+
var execution_result = null
|
|
57
|
+
|
|
58
|
+
# Replace print() calls with custom_print() in the user code
|
|
59
|
+
var modified_code = _replace_print_calls(code)
|
|
60
|
+
|
|
61
|
+
# Use consistent tab indentation in the template
|
|
62
|
+
var script_content = """@tool
|
|
63
|
+
extends Node
|
|
64
|
+
|
|
65
|
+
signal execution_completed
|
|
66
|
+
|
|
67
|
+
# Variable to store the result
|
|
68
|
+
var result = null
|
|
69
|
+
var _output_array = []
|
|
70
|
+
var _error_message = ""
|
|
71
|
+
var _parent
|
|
72
|
+
|
|
73
|
+
# Custom print function that stores output in the array
|
|
74
|
+
func custom_print(values):
|
|
75
|
+
# Convert array of values to a single string
|
|
76
|
+
var output_str = ""
|
|
77
|
+
if values is Array:
|
|
78
|
+
for i in range(values.size()):
|
|
79
|
+
if i > 0:
|
|
80
|
+
output_str += " "
|
|
81
|
+
output_str += str(values[i])
|
|
82
|
+
else:
|
|
83
|
+
output_str = str(values)
|
|
84
|
+
|
|
85
|
+
_output_array.append(output_str)
|
|
86
|
+
print(output_str) # Still print to the console for debugging
|
|
87
|
+
|
|
88
|
+
func run():
|
|
89
|
+
print("Executing script... ready func")
|
|
90
|
+
_parent = get_parent()
|
|
91
|
+
var scene = get_tree().edited_scene_root
|
|
92
|
+
|
|
93
|
+
# Execute the provided code
|
|
94
|
+
var err = _execute_code()
|
|
95
|
+
|
|
96
|
+
# If there was an error, store it
|
|
97
|
+
if err != OK:
|
|
98
|
+
_error_message = "Failed to execute script with error: " + str(err)
|
|
99
|
+
|
|
100
|
+
# Signal that execution is complete
|
|
101
|
+
execution_completed.emit()
|
|
102
|
+
|
|
103
|
+
func _execute_code():
|
|
104
|
+
# USER CODE START
|
|
105
|
+
{user_code}
|
|
106
|
+
# USER CODE END
|
|
107
|
+
return OK
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# Process the user code to ensure consistent indentation
|
|
111
|
+
# This helps prevent "mixed tabs and spaces" errors
|
|
112
|
+
var processed_lines = []
|
|
113
|
+
var lines = modified_code.split("\n")
|
|
114
|
+
for line in lines:
|
|
115
|
+
# Replace any spaces at the beginning with tabs
|
|
116
|
+
var processed_line = line
|
|
117
|
+
|
|
118
|
+
# If line starts with spaces, replace with a tab
|
|
119
|
+
var space_count = 0
|
|
120
|
+
for i in range(line.length()):
|
|
121
|
+
if line[i] == " ":
|
|
122
|
+
space_count += 1
|
|
123
|
+
else:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# If we found spaces at the beginning, replace with tabs
|
|
127
|
+
if space_count > 0:
|
|
128
|
+
# Create tabs based on space count (e.g., 4 spaces = 1 tab)
|
|
129
|
+
var tabs = ""
|
|
130
|
+
for _i in range(space_count / 4): # Integer division
|
|
131
|
+
tabs += "\t"
|
|
132
|
+
processed_line = tabs + line.substr(space_count)
|
|
133
|
+
|
|
134
|
+
processed_lines.append(processed_line)
|
|
135
|
+
|
|
136
|
+
var indented_code = ""
|
|
137
|
+
for line in processed_lines:
|
|
138
|
+
indented_code += "\t" + line + "\n"
|
|
139
|
+
|
|
140
|
+
script_content = script_content.replace("{user_code}", indented_code)
|
|
141
|
+
script.source_code = script_content
|
|
142
|
+
|
|
143
|
+
# Check for script errors during parsing
|
|
144
|
+
var error = script.reload()
|
|
145
|
+
if error != OK:
|
|
146
|
+
var parse_tail = _extract_log_tail(parse_log_snapshot)
|
|
147
|
+
var parse_message = "Script parsing error: " + str(error)
|
|
148
|
+
if not parse_tail.is_empty():
|
|
149
|
+
parse_message += "\n" + "\n".join(parse_tail)
|
|
150
|
+
remove_child(script_node)
|
|
151
|
+
script_node.queue_free()
|
|
152
|
+
return _send_error(client_id, parse_message, command_id)
|
|
153
|
+
|
|
154
|
+
# Assign the script to the node
|
|
155
|
+
script_node.set_script(script)
|
|
156
|
+
|
|
157
|
+
# Connect to the execution_completed signal
|
|
158
|
+
script_node.connect("execution_completed", _on_script_execution_completed.bind(script_node, client_id, command_id))
|
|
159
|
+
|
|
160
|
+
var execution_log_snapshot = _capture_log_snapshot()
|
|
161
|
+
_track_pending_execution(script_node, client_id, command_id, execution_log_snapshot)
|
|
162
|
+
script_node.run()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Signal handler for when script execution completes
|
|
166
|
+
func _on_script_execution_completed(script_node: Node, client_id: int, command_id: String) -> void:
|
|
167
|
+
var pending = _pop_pending_execution(script_node)
|
|
168
|
+
var log_snapshot = pending.get("log_snapshot", {})
|
|
169
|
+
var log_tail = _extract_log_tail(log_snapshot)
|
|
170
|
+
|
|
171
|
+
# Collect results safely by checking if properties exist
|
|
172
|
+
var execution_result = script_node.get("result")
|
|
173
|
+
var output = script_node._output_array
|
|
174
|
+
var error_message = script_node._error_message
|
|
175
|
+
|
|
176
|
+
# Clean up
|
|
177
|
+
remove_child(script_node)
|
|
178
|
+
script_node.queue_free()
|
|
179
|
+
|
|
180
|
+
# Build the response
|
|
181
|
+
var result_data = {
|
|
182
|
+
"success": error_message.is_empty(),
|
|
183
|
+
"output": output
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
print("result_data: ", result_data)
|
|
187
|
+
|
|
188
|
+
if not error_message.is_empty():
|
|
189
|
+
result_data["error"] = error_message
|
|
190
|
+
if not log_tail.is_empty():
|
|
191
|
+
result_data["debug_log_tail"] = log_tail
|
|
192
|
+
elif execution_result != null:
|
|
193
|
+
result_data["result"] = execution_result
|
|
194
|
+
|
|
195
|
+
_send_success(client_id, result_data, command_id)
|
|
196
|
+
|
|
197
|
+
# Replace print() calls with custom_print() in the user code
|
|
198
|
+
func _replace_print_calls(code: String) -> String:
|
|
199
|
+
var modified_code := ""
|
|
200
|
+
var search_index := 0
|
|
201
|
+
|
|
202
|
+
while search_index < code.length():
|
|
203
|
+
var match_index = code.find("print", search_index)
|
|
204
|
+
if match_index == -1:
|
|
205
|
+
modified_code += code.substr(search_index)
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
modified_code += code.substr(search_index, match_index - search_index)
|
|
209
|
+
var prev_char = code.substr(match_index - 1, 1) if match_index > 0 else ""
|
|
210
|
+
var next_char = code.substr(match_index + 5, 1) if match_index + 5 < code.length() else ""
|
|
211
|
+
|
|
212
|
+
if _is_identifier_char(prev_char) or _is_identifier_char(next_char):
|
|
213
|
+
modified_code += "print"
|
|
214
|
+
search_index = match_index + 5
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
var paren_index = _skip_whitespace(code, match_index + 5)
|
|
218
|
+
if paren_index >= code.length() or code[paren_index] != "(":
|
|
219
|
+
modified_code += "print"
|
|
220
|
+
search_index = match_index + 5
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
var closing_index = _find_matching_paren(code, paren_index)
|
|
224
|
+
if closing_index == -1:
|
|
225
|
+
modified_code += code.substr(match_index)
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
var inner_content = code.substr(paren_index + 1, closing_index - paren_index - 1)
|
|
229
|
+
modified_code += "custom_print([" + inner_content + "])"
|
|
230
|
+
search_index = closing_index + 1
|
|
231
|
+
|
|
232
|
+
return modified_code
|
|
233
|
+
|
|
234
|
+
func _skip_whitespace(text: String, start_index: int) -> int:
|
|
235
|
+
var index = start_index
|
|
236
|
+
while index < text.length():
|
|
237
|
+
var char = text.substr(index, 1)
|
|
238
|
+
if char != " " and char != "\t" and char != "\n" and char != "\r":
|
|
239
|
+
break
|
|
240
|
+
index += 1
|
|
241
|
+
return index
|
|
242
|
+
|
|
243
|
+
func _is_identifier_char(char: String) -> bool:
|
|
244
|
+
if char.is_empty():
|
|
245
|
+
return false
|
|
246
|
+
var code_point = char.unicode_at(0)
|
|
247
|
+
var is_digit = code_point >= 48 and code_point <= 57
|
|
248
|
+
var is_lower = code_point >= 97 and code_point <= 122
|
|
249
|
+
var is_upper = code_point >= 65 and code_point <= 90
|
|
250
|
+
return is_digit or is_lower or is_upper or char == "_"
|
|
251
|
+
|
|
252
|
+
func _find_matching_paren(text: String, open_index: int) -> int:
|
|
253
|
+
var depth = 1
|
|
254
|
+
var index = open_index + 1
|
|
255
|
+
var in_string = false
|
|
256
|
+
var string_delimiter = ""
|
|
257
|
+
var escape_next = false
|
|
258
|
+
|
|
259
|
+
while index < text.length():
|
|
260
|
+
var char = text[index]
|
|
261
|
+
if in_string:
|
|
262
|
+
if escape_next:
|
|
263
|
+
escape_next = false
|
|
264
|
+
elif char == "\\":
|
|
265
|
+
escape_next = true
|
|
266
|
+
elif char == string_delimiter:
|
|
267
|
+
in_string = false
|
|
268
|
+
else:
|
|
269
|
+
if char == "\"" or char == "'":
|
|
270
|
+
in_string = true
|
|
271
|
+
string_delimiter = char
|
|
272
|
+
elif char == "(":
|
|
273
|
+
depth += 1
|
|
274
|
+
elif char == ")":
|
|
275
|
+
depth -= 1
|
|
276
|
+
if depth == 0:
|
|
277
|
+
return index
|
|
278
|
+
index += 1
|
|
279
|
+
return -1
|
|
280
|
+
|
|
281
|
+
func _get_debug_output_publisher():
|
|
282
|
+
if Engine.has_meta("MCPDebugOutputPublisher"):
|
|
283
|
+
var publisher = Engine.get_meta("MCPDebugOutputPublisher")
|
|
284
|
+
if publisher and publisher.has_method("get_full_log_text"):
|
|
285
|
+
return publisher
|
|
286
|
+
return null
|
|
287
|
+
|
|
288
|
+
func _capture_log_snapshot() -> Dictionary:
|
|
289
|
+
var publisher = _get_debug_output_publisher()
|
|
290
|
+
if publisher == null:
|
|
291
|
+
return {}
|
|
292
|
+
var text = publisher.get_full_log_text()
|
|
293
|
+
return {
|
|
294
|
+
"publisher": publisher,
|
|
295
|
+
"length": text.length()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func _extract_log_tail(snapshot: Dictionary) -> Array:
|
|
299
|
+
if snapshot.is_empty():
|
|
300
|
+
return []
|
|
301
|
+
if not snapshot.has("publisher") or not snapshot.has("length"):
|
|
302
|
+
return []
|
|
303
|
+
var publisher = snapshot["publisher"]
|
|
304
|
+
if publisher == null or not publisher.has_method("get_full_log_text"):
|
|
305
|
+
return []
|
|
306
|
+
var baseline = int(snapshot["length"])
|
|
307
|
+
var text = publisher.get_full_log_text()
|
|
308
|
+
if baseline < 0 or baseline > text.length():
|
|
309
|
+
baseline = max(0, text.length() - MAX_LOG_TAIL_CHARS)
|
|
310
|
+
var delta = text.substr(baseline)
|
|
311
|
+
if delta.length() > MAX_LOG_TAIL_CHARS:
|
|
312
|
+
delta = delta.substr(delta.length() - MAX_LOG_TAIL_CHARS)
|
|
313
|
+
delta = delta.strip_edges()
|
|
314
|
+
if delta.is_empty():
|
|
315
|
+
return []
|
|
316
|
+
var lines = delta.split("\n", false)
|
|
317
|
+
if lines.size() > MAX_LOG_TAIL_LINES:
|
|
318
|
+
lines = lines.slice(lines.size() - MAX_LOG_TAIL_LINES, lines.size())
|
|
319
|
+
return lines
|
|
320
|
+
|
|
321
|
+
func _track_pending_execution(script_node: Node, client_id: int, command_id: String, log_snapshot: Dictionary) -> void:
|
|
322
|
+
var execution_id = script_node.get_instance_id()
|
|
323
|
+
var timer := Timer.new()
|
|
324
|
+
timer.one_shot = true
|
|
325
|
+
timer.wait_time = EXECUTION_TIMEOUT_SECONDS
|
|
326
|
+
add_child(timer)
|
|
327
|
+
timer.connect("timeout", Callable(self, "_on_execution_timeout").bind(execution_id, client_id, command_id))
|
|
328
|
+
timer.start()
|
|
329
|
+
_pending_executions[execution_id] = {
|
|
330
|
+
"client_id": client_id,
|
|
331
|
+
"command_id": command_id,
|
|
332
|
+
"log_snapshot": log_snapshot,
|
|
333
|
+
"timer": timer,
|
|
334
|
+
"node": script_node
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
func _pop_pending_execution(script_node: Node) -> Dictionary:
|
|
338
|
+
var execution_id = script_node.get_instance_id()
|
|
339
|
+
if not _pending_executions.has(execution_id):
|
|
340
|
+
return {}
|
|
341
|
+
var pending: Dictionary = _pending_executions[execution_id]
|
|
342
|
+
_pending_executions.erase(execution_id)
|
|
343
|
+
var timer = pending.get("timer", null)
|
|
344
|
+
if timer and is_instance_valid(timer):
|
|
345
|
+
timer.queue_free()
|
|
346
|
+
return pending
|
|
347
|
+
|
|
348
|
+
func _on_execution_timeout(execution_id: int, client_id: int, command_id: String) -> void:
|
|
349
|
+
if not _pending_executions.has(execution_id):
|
|
350
|
+
return
|
|
351
|
+
var pending: Dictionary = _pending_executions[execution_id]
|
|
352
|
+
_pending_executions.erase(execution_id)
|
|
353
|
+
var timer = pending.get("timer", null)
|
|
354
|
+
if timer and is_instance_valid(timer):
|
|
355
|
+
timer.queue_free()
|
|
356
|
+
var script_node = pending.get("node", null)
|
|
357
|
+
if script_node and is_instance_valid(script_node):
|
|
358
|
+
if script_node.is_inside_tree():
|
|
359
|
+
remove_child(script_node)
|
|
360
|
+
script_node.queue_free()
|
|
361
|
+
var log_tail = _extract_log_tail(pending.get("log_snapshot", {}))
|
|
362
|
+
var message = "Script execution timed out before completion."
|
|
363
|
+
if not log_tail.is_empty():
|
|
364
|
+
message += "\n" + "\n".join(log_tail)
|
|
365
|
+
_send_error(client_id, message, command_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://caqthbi04b8ym
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
@tool
|
|
2
|
+
class_name MCPInputCommands
|
|
3
|
+
extends MCPBaseCommandProcessor
|
|
4
|
+
|
|
5
|
+
## Command processor for input simulation in running games.
|
|
6
|
+
## Sends input commands through the debugger bridge to the runtime input handler.
|
|
7
|
+
|
|
8
|
+
const INPUT_CAPTURE_NAME := "mcp_input"
|
|
9
|
+
const DEFAULT_TIMEOUT_MS := 2000
|
|
10
|
+
|
|
11
|
+
var _next_request_id: int = 1
|
|
12
|
+
var _pending_requests: Dictionary = {}
|
|
13
|
+
|
|
14
|
+
func _get_runtime_bridge() -> MCPRuntimeDebuggerBridge:
|
|
15
|
+
if Engine.has_meta("MCPRuntimeDebuggerBridge"):
|
|
16
|
+
return Engine.get_meta("MCPRuntimeDebuggerBridge") as MCPRuntimeDebuggerBridge
|
|
17
|
+
return null
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
func process_command(client_id: int, command_type: String, params: Dictionary, command_id: String) -> bool:
|
|
21
|
+
match command_type:
|
|
22
|
+
"simulate_action_press":
|
|
23
|
+
_handle_action_press(client_id, params, command_id)
|
|
24
|
+
return true
|
|
25
|
+
"simulate_action_release":
|
|
26
|
+
_handle_action_release(client_id, params, command_id)
|
|
27
|
+
return true
|
|
28
|
+
"simulate_action_tap":
|
|
29
|
+
_handle_action_tap(client_id, params, command_id)
|
|
30
|
+
return true
|
|
31
|
+
"simulate_mouse_click":
|
|
32
|
+
_handle_mouse_click(client_id, params, command_id)
|
|
33
|
+
return true
|
|
34
|
+
"simulate_mouse_move":
|
|
35
|
+
_handle_mouse_move(client_id, params, command_id)
|
|
36
|
+
return true
|
|
37
|
+
"simulate_drag":
|
|
38
|
+
_handle_drag(client_id, params, command_id)
|
|
39
|
+
return true
|
|
40
|
+
"simulate_key_press":
|
|
41
|
+
_handle_key_press(client_id, params, command_id)
|
|
42
|
+
return true
|
|
43
|
+
"simulate_input_sequence":
|
|
44
|
+
_handle_input_sequence(client_id, params, command_id)
|
|
45
|
+
return true
|
|
46
|
+
"get_input_actions":
|
|
47
|
+
_handle_get_input_actions(client_id, params, command_id)
|
|
48
|
+
return true
|
|
49
|
+
|
|
50
|
+
return false
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
func _handle_action_press(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
54
|
+
var action := str(params.get("action", ""))
|
|
55
|
+
if action.is_empty():
|
|
56
|
+
_send_error(client_id, "Action name is required", command_id)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
var strength := float(params.get("strength", 1.0))
|
|
60
|
+
|
|
61
|
+
var result := await _send_input_command("action_press", [action, strength])
|
|
62
|
+
if result.has("error"):
|
|
63
|
+
_send_error(client_id, result["error"], command_id)
|
|
64
|
+
else:
|
|
65
|
+
_send_success(client_id, result, command_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
func _handle_action_release(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
69
|
+
var action := str(params.get("action", ""))
|
|
70
|
+
if action.is_empty():
|
|
71
|
+
_send_error(client_id, "Action name is required", command_id)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
var result := await _send_input_command("action_release", [action])
|
|
75
|
+
if result.has("error"):
|
|
76
|
+
_send_error(client_id, result["error"], command_id)
|
|
77
|
+
else:
|
|
78
|
+
_send_success(client_id, result, command_id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
func _handle_action_tap(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
82
|
+
var action := str(params.get("action", ""))
|
|
83
|
+
if action.is_empty():
|
|
84
|
+
_send_error(client_id, "Action name is required", command_id)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
var duration_ms := int(params.get("duration_ms", 100))
|
|
88
|
+
|
|
89
|
+
var result := await _send_input_command("action_tap", [action, duration_ms])
|
|
90
|
+
if result.has("error"):
|
|
91
|
+
_send_error(client_id, result["error"], command_id)
|
|
92
|
+
else:
|
|
93
|
+
_send_success(client_id, result, command_id)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
func _handle_mouse_click(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
97
|
+
var x := float(params.get("x", 0))
|
|
98
|
+
var y := float(params.get("y", 0))
|
|
99
|
+
var button_str := str(params.get("button", "left")).to_lower()
|
|
100
|
+
var double_click := bool(params.get("double_click", false))
|
|
101
|
+
|
|
102
|
+
var button := MOUSE_BUTTON_LEFT
|
|
103
|
+
match button_str:
|
|
104
|
+
"right":
|
|
105
|
+
button = MOUSE_BUTTON_RIGHT
|
|
106
|
+
"middle":
|
|
107
|
+
button = MOUSE_BUTTON_MIDDLE
|
|
108
|
+
|
|
109
|
+
var options := {
|
|
110
|
+
"x": x,
|
|
111
|
+
"y": y,
|
|
112
|
+
"button": button,
|
|
113
|
+
"double_click": double_click
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var result := await _send_input_command("mouse_click", [options])
|
|
117
|
+
if result.has("error"):
|
|
118
|
+
_send_error(client_id, result["error"], command_id)
|
|
119
|
+
else:
|
|
120
|
+
_send_success(client_id, result, command_id)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
func _handle_mouse_move(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
124
|
+
var x := float(params.get("x", 0))
|
|
125
|
+
var y := float(params.get("y", 0))
|
|
126
|
+
|
|
127
|
+
var options := {
|
|
128
|
+
"x": x,
|
|
129
|
+
"y": y
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var result := await _send_input_command("mouse_move", [options])
|
|
133
|
+
if result.has("error"):
|
|
134
|
+
_send_error(client_id, result["error"], command_id)
|
|
135
|
+
else:
|
|
136
|
+
_send_success(client_id, result, command_id)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
func _handle_drag(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
140
|
+
var start_x := float(params.get("start_x", 0))
|
|
141
|
+
var start_y := float(params.get("start_y", 0))
|
|
142
|
+
var end_x := float(params.get("end_x", 0))
|
|
143
|
+
var end_y := float(params.get("end_y", 0))
|
|
144
|
+
var duration_ms := int(params.get("duration_ms", 200))
|
|
145
|
+
var steps := int(params.get("steps", 10))
|
|
146
|
+
|
|
147
|
+
var button_str := str(params.get("button", "left")).to_lower()
|
|
148
|
+
var button := MOUSE_BUTTON_LEFT
|
|
149
|
+
match button_str:
|
|
150
|
+
"right":
|
|
151
|
+
button = MOUSE_BUTTON_RIGHT
|
|
152
|
+
"middle":
|
|
153
|
+
button = MOUSE_BUTTON_MIDDLE
|
|
154
|
+
|
|
155
|
+
var options := {
|
|
156
|
+
"start_x": start_x,
|
|
157
|
+
"start_y": start_y,
|
|
158
|
+
"end_x": end_x,
|
|
159
|
+
"end_y": end_y,
|
|
160
|
+
"duration_ms": duration_ms,
|
|
161
|
+
"steps": steps,
|
|
162
|
+
"button": button
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
# Drag operations can take longer, adjust timeout
|
|
166
|
+
var timeout := duration_ms + 1000
|
|
167
|
+
|
|
168
|
+
var result := await _send_input_command("drag", [options], timeout)
|
|
169
|
+
if result.has("error"):
|
|
170
|
+
_send_error(client_id, result["error"], command_id)
|
|
171
|
+
else:
|
|
172
|
+
_send_success(client_id, result, command_id)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
func _handle_key_press(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
176
|
+
var key := str(params.get("key", ""))
|
|
177
|
+
if key.is_empty():
|
|
178
|
+
_send_error(client_id, "Key is required", command_id)
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
var duration_ms := int(params.get("duration_ms", 100))
|
|
182
|
+
var modifiers := params.get("modifiers", {})
|
|
183
|
+
if typeof(modifiers) != TYPE_DICTIONARY:
|
|
184
|
+
modifiers = {}
|
|
185
|
+
|
|
186
|
+
var options := {
|
|
187
|
+
"key": key,
|
|
188
|
+
"duration_ms": duration_ms,
|
|
189
|
+
"modifiers": modifiers
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
var result := await _send_input_command("key_press", [options])
|
|
193
|
+
if result.has("error"):
|
|
194
|
+
_send_error(client_id, result["error"], command_id)
|
|
195
|
+
else:
|
|
196
|
+
_send_success(client_id, result, command_id)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
func _handle_input_sequence(client_id: int, params: Dictionary, command_id: String) -> void:
|
|
200
|
+
var sequence := params.get("sequence", [])
|
|
201
|
+
if typeof(sequence) != TYPE_ARRAY:
|
|
202
|
+
_send_error(client_id, "Sequence must be an array", command_id)
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if sequence.is_empty():
|
|
206
|
+
_send_error(client_id, "Sequence cannot be empty", command_id)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Calculate timeout based on sequence length and potential wait times
|
|
210
|
+
var total_wait := 0
|
|
211
|
+
for step in sequence:
|
|
212
|
+
if typeof(step) == TYPE_DICTIONARY:
|
|
213
|
+
total_wait += int(step.get("duration_ms", 100))
|
|
214
|
+
var timeout := total_wait + 2000
|
|
215
|
+
|
|
216
|
+
var result := await _send_input_command("input_sequence", [sequence], timeout)
|
|
217
|
+
if result.has("error"):
|
|
218
|
+
_send_error(client_id, result["error"], command_id)
|
|
219
|
+
else:
|
|
220
|
+
_send_success(client_id, result, command_id)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
func _handle_get_input_actions(client_id: int, _params: Dictionary, command_id: String) -> void:
|
|
224
|
+
var result := await _send_input_command("get_input_actions", [])
|
|
225
|
+
if result.has("error"):
|
|
226
|
+
_send_error(client_id, result["error"], command_id)
|
|
227
|
+
else:
|
|
228
|
+
_send_success(client_id, result, command_id)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
func _send_input_command(action: String, data: Array, timeout_ms: int = DEFAULT_TIMEOUT_MS) -> Dictionary:
|
|
232
|
+
var runtime_bridge := _get_runtime_bridge()
|
|
233
|
+
if runtime_bridge == null:
|
|
234
|
+
return { "error": "Runtime debugger bridge not available. Ensure the project is running." }
|
|
235
|
+
|
|
236
|
+
# Get active session
|
|
237
|
+
var sessions := runtime_bridge.get_sessions()
|
|
238
|
+
var active_session = null
|
|
239
|
+
var session_id := -1
|
|
240
|
+
|
|
241
|
+
for i in range(sessions.size()):
|
|
242
|
+
var session = sessions[i]
|
|
243
|
+
if session and session.has_method("is_active") and session.is_active():
|
|
244
|
+
active_session = session
|
|
245
|
+
session_id = i
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if active_session == null:
|
|
249
|
+
return { "error": "No active runtime session. Start the project with debugger attached." }
|
|
250
|
+
|
|
251
|
+
# Generate request ID
|
|
252
|
+
var request_id := _next_request_id
|
|
253
|
+
_next_request_id += 1
|
|
254
|
+
|
|
255
|
+
# Prepare payload - prepend request_id to data
|
|
256
|
+
var payload := Array()
|
|
257
|
+
payload.append(request_id)
|
|
258
|
+
for item in data:
|
|
259
|
+
payload.append(item)
|
|
260
|
+
|
|
261
|
+
# Store pending request
|
|
262
|
+
_pending_requests[request_id] = {
|
|
263
|
+
"session_id": session_id,
|
|
264
|
+
"action": action,
|
|
265
|
+
"timestamp": Time.get_ticks_msec()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Send message to runtime
|
|
269
|
+
var message_name := "%s:%s" % [INPUT_CAPTURE_NAME, action]
|
|
270
|
+
active_session.send_message(message_name, payload)
|
|
271
|
+
|
|
272
|
+
# Wait for response with timeout
|
|
273
|
+
var deadline := Time.get_ticks_msec() + timeout_ms
|
|
274
|
+
var result: Dictionary = {}
|
|
275
|
+
|
|
276
|
+
while Time.get_ticks_msec() < deadline:
|
|
277
|
+
# Check if we have a result
|
|
278
|
+
if _has_input_result(session_id, request_id):
|
|
279
|
+
result = _take_input_result(session_id, request_id)
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
# Wait a frame
|
|
283
|
+
if get_tree():
|
|
284
|
+
await get_tree().process_frame
|
|
285
|
+
else:
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
# Clean up pending request
|
|
289
|
+
_pending_requests.erase(request_id)
|
|
290
|
+
|
|
291
|
+
if result.is_empty():
|
|
292
|
+
return { "error": "Input command timed out. Ensure the game has the MCP input handler autoload." }
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
func _has_input_result(session_id: int, request_id: int) -> bool:
|
|
298
|
+
var runtime_bridge := _get_runtime_bridge()
|
|
299
|
+
if runtime_bridge == null:
|
|
300
|
+
return false
|
|
301
|
+
|
|
302
|
+
# Check if runtime bridge has input results
|
|
303
|
+
if runtime_bridge.has_method("has_input_result"):
|
|
304
|
+
return runtime_bridge.has_input_result(session_id, request_id)
|
|
305
|
+
|
|
306
|
+
# Fallback: check _sessions directly if accessible
|
|
307
|
+
var sessions_dict = runtime_bridge.get("_sessions")
|
|
308
|
+
if sessions_dict and sessions_dict.has(session_id):
|
|
309
|
+
var state: Dictionary = sessions_dict[session_id]
|
|
310
|
+
var input_results: Dictionary = state.get("input_results", {})
|
|
311
|
+
return input_results.has(request_id)
|
|
312
|
+
|
|
313
|
+
return false
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
func _take_input_result(session_id: int, request_id: int) -> Dictionary:
|
|
317
|
+
var runtime_bridge := _get_runtime_bridge()
|
|
318
|
+
if runtime_bridge == null:
|
|
319
|
+
return {}
|
|
320
|
+
|
|
321
|
+
# Check if runtime bridge has take method
|
|
322
|
+
if runtime_bridge.has_method("take_input_result"):
|
|
323
|
+
return runtime_bridge.take_input_result(session_id, request_id)
|
|
324
|
+
|
|
325
|
+
# Fallback: access _sessions directly if accessible
|
|
326
|
+
var sessions_dict = runtime_bridge.get("_sessions")
|
|
327
|
+
if sessions_dict and sessions_dict.has(session_id):
|
|
328
|
+
var state: Dictionary = sessions_dict[session_id]
|
|
329
|
+
var input_results: Dictionary = state.get("input_results", {})
|
|
330
|
+
if input_results.has(request_id):
|
|
331
|
+
var result: Dictionary = input_results[request_id]
|
|
332
|
+
input_results.erase(request_id)
|
|
333
|
+
state["input_results"] = input_results
|
|
334
|
+
sessions_dict[session_id] = state
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
return {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://n3hflqvxu06j
|