godot-mcp-runtime 2.2.3 → 3.0.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.
- package/README.md +30 -93
- package/dist/dispatch.d.ts +1 -11
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +7 -8
- package/dist/dispatch.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -10
- package/dist/index.js.map +1 -1
- package/dist/scripts/godot_operations.gd +134 -283
- package/dist/scripts/mcp_bridge.gd +210 -43
- package/dist/tools/autoload-tools.d.ts +51 -0
- package/dist/tools/autoload-tools.d.ts.map +1 -0
- package/dist/tools/autoload-tools.js +187 -0
- package/dist/tools/autoload-tools.js.map +1 -0
- package/dist/tools/node-tools.d.ts +9 -78
- package/dist/tools/node-tools.d.ts.map +1 -1
- package/dist/tools/node-tools.js +114 -310
- package/dist/tools/node-tools.js.map +1 -1
- package/dist/tools/project-tools.d.ts +0 -168
- package/dist/tools/project-tools.d.ts.map +1 -1
- package/dist/tools/project-tools.js +120 -1192
- package/dist/tools/project-tools.js.map +1 -1
- package/dist/tools/runtime-tools.d.ts +108 -0
- package/dist/tools/runtime-tools.d.ts.map +1 -0
- package/dist/tools/runtime-tools.js +808 -0
- package/dist/tools/runtime-tools.js.map +1 -0
- package/dist/tools/scene-tools.d.ts +6 -48
- package/dist/tools/scene-tools.d.ts.map +1 -1
- package/dist/tools/scene-tools.js +67 -211
- package/dist/tools/scene-tools.js.map +1 -1
- package/dist/tools/validate-tools.d.ts.map +1 -1
- package/dist/tools/validate-tools.js +35 -29
- package/dist/tools/validate-tools.js.map +1 -1
- package/dist/utils/autoload-ini.d.ts +32 -0
- package/dist/utils/autoload-ini.d.ts.map +1 -0
- package/dist/utils/autoload-ini.js +111 -0
- package/dist/utils/autoload-ini.js.map +1 -0
- package/dist/utils/bridge-manager.d.ts +29 -0
- package/dist/utils/bridge-manager.d.ts.map +1 -0
- package/dist/utils/bridge-manager.js +136 -0
- package/dist/utils/bridge-manager.js.map +1 -0
- package/dist/utils/bridge-protocol.d.ts +34 -0
- package/dist/utils/bridge-protocol.d.ts.map +1 -0
- package/dist/utils/bridge-protocol.js +65 -0
- package/dist/utils/bridge-protocol.js.map +1 -0
- package/dist/utils/godot-runner.d.ts +70 -15
- package/dist/utils/godot-runner.d.ts.map +1 -1
- package/dist/utils/godot-runner.js +309 -277
- package/dist/utils/godot-runner.js.map +1 -1
- package/dist/utils/handler-helpers.d.ts +34 -0
- package/dist/utils/handler-helpers.d.ts.map +1 -0
- package/dist/utils/handler-helpers.js +55 -0
- package/dist/utils/handler-helpers.js.map +1 -0
- package/dist/utils/logger.d.ts +3 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +11 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +7 -4
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { join, sep } from 'path';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { normalizeParameters, validateProjectArgs, createErrorResponse, getErrorMessage, BRIDGE_WAIT_SPAWNED_TIMEOUT_MS, } from '../utils/godot-runner.js';
|
|
4
|
+
import { attachRuntimeWarnings, parseBridgeJson, MAX_RUNTIME_ERROR_CONTEXT_LINES, } from '../utils/handler-helpers.js';
|
|
5
|
+
import { logDebug } from '../utils/logger.js';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
const SCREENSHOT_RESPONSE_MODES = ['full', 'preview', 'path_only'];
|
|
8
|
+
const DEFAULT_PREVIEW_MAX_WIDTH = 960;
|
|
9
|
+
const DEFAULT_PREVIEW_MAX_HEIGHT = 540;
|
|
10
|
+
// --- Tool definitions ---
|
|
11
|
+
export const runtimeToolDefinitions = [
|
|
12
|
+
{
|
|
13
|
+
name: 'launch_editor',
|
|
14
|
+
description: 'Open the Godot editor GUI for a project for the human user. Use only when the user explicitly asks to "open the editor"; for any agent-driven work, use the headless scene/node tools (add_node, set_node_properties, etc.) instead — the editor cannot be controlled programmatically. Returns plain-text confirmation after spawning the editor process. Errors if projectPath has no project.godot.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
projectPath: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Path to the Godot project directory',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ['projectPath'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'run_project',
|
|
28
|
+
description: 'Spawn a Godot project as a child process with stdout/stderr captured. Preferred entry to runtime tools — required before take_screenshot, simulate_input, get_ui_elements, run_script, or get_debug_output. For a Godot process you launched yourself, use attach_project instead. Verifies MCP bridge readiness before returning success. Call stop_project when done. Returns plain-text status confirming the bridge is ready. Errors if projectPath is not a Godot project or another run is already active.',
|
|
29
|
+
annotations: { destructiveHint: true },
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
projectPath: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Path to the Godot project directory',
|
|
36
|
+
},
|
|
37
|
+
scene: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Scene to run (path relative to project, e.g. "scenes/main.tscn"). Omit to use the project\'s main scene.',
|
|
40
|
+
},
|
|
41
|
+
background: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
description: 'If true, hides the Godot window off-screen and blocks all physical keyboard and mouse input, while keeping programmatic input (simulate_input, run_script) and screenshots fully active. Useful for automated agent-driven testing where the window should not be visible or interactive.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ['projectPath'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'attach_project',
|
|
51
|
+
description: 'Attach runtime MCP tools to a manually launched Godot process without spawning one. Use only when something other than MCP is running Godot (debugger attached, custom CLI flags, IDE run) — for the standard case, use run_project. Injects the McpBridge autoload, then waits up to 15s for the bridge to respond. If you are launching Godot in parallel, kick the launch off concurrently with this call so the wait absorbs startup. bridge.inject is idempotent, so retrying after a missed window is safe. Use detach_project or stop_project when done. get_debug_output is unavailable in attached mode (stdout/stderr not captured). Returns plain-text status confirming the bridge is ready in attached mode.',
|
|
52
|
+
annotations: { destructiveHint: true },
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
projectPath: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Path to the Godot project directory',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ['projectPath'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'detach_project',
|
|
66
|
+
description: 'Clear attached-mode runtime state and remove the injected McpBridge autoload. Does NOT stop the manually launched Godot process — that stays running. Use after attach_project when you are done driving the game from MCP. For spawned sessions (run_project), use stop_project instead. Returns { message, externalProcessPreserved }. Errors if called outside an attached session.',
|
|
67
|
+
annotations: { destructiveHint: true },
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {},
|
|
71
|
+
required: [],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'get_debug_output',
|
|
76
|
+
description: 'Get captured stdout/stderr from a spawned Godot project. Use whenever runtime tools fail unexpectedly — script errors, missing nodes, and crash backtraces all surface here. Requires run_project (not attach_project; attached mode does not capture output). Returns { output, errors, running, exitCode? } with the last `limit` lines (default 200, from the end). Reports attached-mode unavailability gracefully.',
|
|
77
|
+
annotations: { readOnlyHint: true },
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
limit: {
|
|
82
|
+
type: 'number',
|
|
83
|
+
description: 'Max lines to return (default: 200, from end of output)',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: [],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'stop_project',
|
|
91
|
+
description: 'Stop the spawned Godot project and clean up MCP bridge state. Always call when done with runtime testing — even after a crash — to free the single process slot so run_project can be called again. For attached sessions, this detaches without killing the externally launched process. Returns { message, mode, externalProcessPreserved, finalOutput, finalErrors }. Errors if no session is active.',
|
|
92
|
+
annotations: { destructiveHint: true },
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {},
|
|
96
|
+
required: [],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'take_screenshot',
|
|
101
|
+
description: 'Capture a PNG of the running viewport. responseMode: preview (default — saves full PNG, returns bounded inline preview at 960x540), full (full inline PNG; use for small text or pixel-level inspection), path_only (saved-path only, no inline image). Saved under .mcp/screenshots. Returns: { responseMode, path, size, previewPath?, previewSize?, warnings? } as JSON text, plus an inline image for full/preview. Errors if no session or bridge times out (default 10000ms).',
|
|
102
|
+
annotations: { readOnlyHint: true },
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
timeout: {
|
|
107
|
+
type: 'number',
|
|
108
|
+
description: 'Timeout in milliseconds to wait for the screenshot (default: 10000)',
|
|
109
|
+
},
|
|
110
|
+
responseMode: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
enum: ['full', 'preview', 'path_only'],
|
|
113
|
+
description: 'Response payload mode. "preview" returns a bounded inline preview plus paths (default). "full" returns the full inline PNG. "path_only" returns paths only.',
|
|
114
|
+
},
|
|
115
|
+
previewMaxWidth: {
|
|
116
|
+
type: 'number',
|
|
117
|
+
description: 'Maximum preview width in pixels when responseMode is "preview" (default: 960)',
|
|
118
|
+
},
|
|
119
|
+
previewMaxHeight: {
|
|
120
|
+
type: 'number',
|
|
121
|
+
description: 'Maximum preview height in pixels when responseMode is "preview" (default: 540)',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: [],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'simulate_input',
|
|
129
|
+
description: "Simulate sequential input in a running project. Each action's `type` (key, mouse_button, mouse_motion, click_element, action, wait) gates which other fields apply — see per-property docs. For click_element use get_ui_elements first; resolution is by path/name, not visible text. Press/release require two actions; insert wait between for frame ticks. Returns: { success, actions_processed, warnings? }. Errors if no session or any action fails validation.",
|
|
130
|
+
annotations: { destructiveHint: true },
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
actions: {
|
|
135
|
+
type: 'array',
|
|
136
|
+
description: 'Array of input actions to execute sequentially. Each object must have a "type" field.',
|
|
137
|
+
items: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
type: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
enum: ['key', 'mouse_button', 'mouse_motion', 'click_element', 'action', 'wait'],
|
|
143
|
+
description: 'The type of input action',
|
|
144
|
+
},
|
|
145
|
+
key: {
|
|
146
|
+
type: 'string',
|
|
147
|
+
description: '[key] Key name (e.g. "W", "Space", "Escape", "Up")',
|
|
148
|
+
},
|
|
149
|
+
pressed: {
|
|
150
|
+
type: 'boolean',
|
|
151
|
+
description: '[key, mouse_button, action] Whether the input is pressed (true) or released (false)',
|
|
152
|
+
},
|
|
153
|
+
shift: { type: 'boolean', description: '[key] Shift modifier' },
|
|
154
|
+
ctrl: { type: 'boolean', description: '[key] Ctrl modifier' },
|
|
155
|
+
alt: { type: 'boolean', description: '[key] Alt modifier' },
|
|
156
|
+
unicode: {
|
|
157
|
+
type: 'number',
|
|
158
|
+
description: '[key] Unicode codepoint for text-entry Controls (LineEdit, TextEdit). Auto-derived for ASCII letters/digits (respecting shift); pass explicitly for symbols or non-ASCII. E.g. 33 for "!", 64 for "@".',
|
|
159
|
+
},
|
|
160
|
+
button: {
|
|
161
|
+
type: 'string',
|
|
162
|
+
enum: ['left', 'right', 'middle'],
|
|
163
|
+
description: '[mouse_button, click_element] Mouse button (default: left)',
|
|
164
|
+
},
|
|
165
|
+
x: {
|
|
166
|
+
type: 'number',
|
|
167
|
+
description: '[mouse_button, mouse_motion] X position in viewport pixels',
|
|
168
|
+
},
|
|
169
|
+
y: {
|
|
170
|
+
type: 'number',
|
|
171
|
+
description: '[mouse_button, mouse_motion] Y position in viewport pixels',
|
|
172
|
+
},
|
|
173
|
+
relative_x: {
|
|
174
|
+
type: 'number',
|
|
175
|
+
description: '[mouse_motion] Relative X movement in pixels',
|
|
176
|
+
},
|
|
177
|
+
relative_y: {
|
|
178
|
+
type: 'number',
|
|
179
|
+
description: '[mouse_motion] Relative Y movement in pixels',
|
|
180
|
+
},
|
|
181
|
+
double_click: {
|
|
182
|
+
type: 'boolean',
|
|
183
|
+
description: '[mouse_button, click_element] Double click',
|
|
184
|
+
},
|
|
185
|
+
element: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
description: '[click_element] Identifies the UI element to click. Accepts: absolute node path (e.g. "/root/HUD/Button"), relative node path, or node name (BFS matched). Use get_ui_elements to discover valid names and paths.',
|
|
188
|
+
},
|
|
189
|
+
action: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
description: '[action] Godot input action name (as defined in Project Settings > Input Map)',
|
|
192
|
+
},
|
|
193
|
+
strength: {
|
|
194
|
+
type: 'number',
|
|
195
|
+
description: '[action] Action strength (0–1, default 1.0)',
|
|
196
|
+
},
|
|
197
|
+
ms: {
|
|
198
|
+
type: 'number',
|
|
199
|
+
description: '[wait] Duration in milliseconds to pause before the next action',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ['type'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
required: ['actions'],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'get_ui_elements',
|
|
211
|
+
description: 'Walk the running scene tree and return all Control nodes with positions, sizes, types, and text content. Always call this before simulate_input click_element actions to discover valid element names and paths. Requires an active runtime session (run_project or attach_project). visibleOnly defaults true; pass false to include hidden Controls. filter narrows by class. Returns { elements: [{ name, path, type, rect, visible, text?, disabled?, tooltip? }] }.',
|
|
212
|
+
annotations: { readOnlyHint: true },
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
visibleOnly: {
|
|
217
|
+
type: 'boolean',
|
|
218
|
+
description: 'Only return nodes where Control.visible is true (default: true). Set false to include hidden elements.',
|
|
219
|
+
},
|
|
220
|
+
filter: {
|
|
221
|
+
type: 'string',
|
|
222
|
+
description: 'Filter by Control node type (e.g. "Button", "Label", "LineEdit")',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
required: [],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'run_script',
|
|
230
|
+
description: 'Execute a custom GDScript in the live running project with full scene tree access. Requires an active runtime session. Script must extend RefCounted and define func execute(scene_tree: SceneTree) -> Variant. Return values are JSON-serialized (primitives, Vector2/3, Color, Dictionary, Array, and Node path strings). Use print() for debug output — it appears in get_debug_output, not in the result. In spawned mode, stderr runtime errors escalate to errors (when the script returns null) or surface as warnings. Returns { success, result, warnings? } where result is the JSON-serialized return value of execute().',
|
|
231
|
+
annotations: { destructiveHint: true },
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
script: {
|
|
236
|
+
type: 'string',
|
|
237
|
+
description: 'GDScript source code. Must contain "extends RefCounted" and "func execute(scene_tree: SceneTree) -> Variant".',
|
|
238
|
+
},
|
|
239
|
+
timeout: {
|
|
240
|
+
type: 'number',
|
|
241
|
+
description: 'Timeout in ms (default: 30000). Increase for long-running scripts.',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
required: ['script'],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
// --- Helpers ---
|
|
249
|
+
function ensureRuntimeSession(runner, actionDescription) {
|
|
250
|
+
if (!runner.activeSessionMode || !runner.activeProjectPath) {
|
|
251
|
+
return createErrorResponse(`No active runtime session. A project must be running or attached to ${actionDescription}.`, [
|
|
252
|
+
'Use run_project to start a Godot project first',
|
|
253
|
+
'Or use attach_project before launching Godot manually',
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
if (runner.activeSessionMode === 'spawned' &&
|
|
257
|
+
(!runner.activeProcess || runner.activeProcess.hasExited)) {
|
|
258
|
+
return createErrorResponse(`The spawned Godot process has exited and cannot ${actionDescription}.`, [
|
|
259
|
+
'Use get_debug_output to inspect the last captured logs',
|
|
260
|
+
'Call stop_project to clean up, then run_project again',
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
// --- Handlers ---
|
|
266
|
+
export async function handleLaunchEditor(runner, args) {
|
|
267
|
+
args = normalizeParameters(args);
|
|
268
|
+
const v = validateProjectArgs(args);
|
|
269
|
+
if ('isError' in v)
|
|
270
|
+
return v;
|
|
271
|
+
try {
|
|
272
|
+
if (!runner.getGodotPath()) {
|
|
273
|
+
await runner.detectGodotPath();
|
|
274
|
+
if (!runner.getGodotPath()) {
|
|
275
|
+
return createErrorResponse('Could not find a valid Godot executable path', [
|
|
276
|
+
'Ensure Godot is installed correctly',
|
|
277
|
+
'Set GODOT_PATH environment variable',
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
logDebug(`Launching Godot editor for project: ${v.projectPath}`);
|
|
282
|
+
const process = runner.launchEditor(v.projectPath);
|
|
283
|
+
process.on('error', (err) => {
|
|
284
|
+
console.error('Failed to start Godot editor:', err);
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
content: [
|
|
288
|
+
{
|
|
289
|
+
type: 'text',
|
|
290
|
+
text: `Godot editor launched successfully for project at ${v.projectPath}.\nNote: the editor is a GUI application and cannot be controlled programmatically. Use the scene and node editing tools (add_node, set_node_properties, etc.) to modify the project headlessly without the editor.`,
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
return createErrorResponse(`Failed to launch Godot editor: ${getErrorMessage(error)}`, [
|
|
297
|
+
'Ensure Godot is installed correctly',
|
|
298
|
+
'Check if the GODOT_PATH environment variable is set correctly',
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
export async function handleRunProject(runner, args) {
|
|
303
|
+
args = normalizeParameters(args);
|
|
304
|
+
const v = validateProjectArgs(args);
|
|
305
|
+
if ('isError' in v)
|
|
306
|
+
return v;
|
|
307
|
+
try {
|
|
308
|
+
const background = args.background === true;
|
|
309
|
+
runner.runProject(v.projectPath, args.scene, background);
|
|
310
|
+
const bridgeResult = await runner.waitForBridge();
|
|
311
|
+
if (!bridgeResult.ready) {
|
|
312
|
+
if (runner.activeProcess && runner.activeProcess.hasExited) {
|
|
313
|
+
// Tear down the spawned-mode session state so a retry of run_project
|
|
314
|
+
// works without an intervening stop_project.
|
|
315
|
+
await runner.stopProject();
|
|
316
|
+
return createErrorResponse(`Godot process exited before the MCP bridge could initialize.\n${bridgeResult.error || ''}`, [
|
|
317
|
+
'Check get_debug_output for runtime errors',
|
|
318
|
+
'Verify a display server is available (Wayland/X11)',
|
|
319
|
+
'Check for broken autoloads with list_autoloads',
|
|
320
|
+
'Retry run_project once the underlying issue is resolved',
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
const recentErrors = runner.getRecentErrors(20);
|
|
324
|
+
const errorTail = recentErrors.length > 0 ? `\nLast stderr:\n${recentErrors.join('\n')}` : '';
|
|
325
|
+
const lines = [
|
|
326
|
+
`Godot process started, but the MCP bridge did not respond within ${BRIDGE_WAIT_SPAWNED_TIMEOUT_MS / 1000} seconds.`,
|
|
327
|
+
'- The bridge listener never came up — likely an early _ready error or a stuck process holding the port',
|
|
328
|
+
'- Session has been torn down; retry run_project to start a new one',
|
|
329
|
+
errorTail,
|
|
330
|
+
];
|
|
331
|
+
if (background) {
|
|
332
|
+
lines.push('- Background mode: window hidden, physical input blocked');
|
|
333
|
+
}
|
|
334
|
+
// Tear down before returning so hasActiveRuntimeSession() reports false
|
|
335
|
+
// and the next run_project lazy-reconnects cleanly.
|
|
336
|
+
await runner.stopProject();
|
|
337
|
+
return createErrorResponse(lines.join('\n'), [
|
|
338
|
+
'Check for broken autoloads with list_autoloads',
|
|
339
|
+
'Check that the bridge port (default 9900) is not occupied by another Godot process',
|
|
340
|
+
'Retry run_project',
|
|
341
|
+
]);
|
|
342
|
+
}
|
|
343
|
+
const lines = [
|
|
344
|
+
'Godot project started and MCP bridge is ready.',
|
|
345
|
+
'- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
|
|
346
|
+
'- Use get_debug_output to check runtime output and errors',
|
|
347
|
+
'- Call stop_project when done',
|
|
348
|
+
];
|
|
349
|
+
if (background) {
|
|
350
|
+
lines.push('- Background mode: window hidden, physical input blocked');
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
return createErrorResponse(`Failed to run Godot project: ${getErrorMessage(error)}`, [
|
|
358
|
+
'Ensure Godot is installed correctly',
|
|
359
|
+
'Check if the GODOT_PATH environment variable is set correctly',
|
|
360
|
+
]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
export async function handleAttachProject(runner, args) {
|
|
364
|
+
args = normalizeParameters(args);
|
|
365
|
+
const v = validateProjectArgs(args);
|
|
366
|
+
if ('isError' in v)
|
|
367
|
+
return v;
|
|
368
|
+
try {
|
|
369
|
+
await runner.attachProject(v.projectPath);
|
|
370
|
+
const bridgeResult = await runner.waitForBridgeAttached();
|
|
371
|
+
if (!bridgeResult.ready) {
|
|
372
|
+
// Tear down the attached-mode session state so retrying with
|
|
373
|
+
// attach_project (or run_project) works without a manual detach first.
|
|
374
|
+
await runner.stopProject();
|
|
375
|
+
return createErrorResponse(`Project attached but the MCP bridge is not ready.\n${bridgeResult.error || ''}`, [
|
|
376
|
+
'If you are launching Godot yourself, run the launch in parallel with attach_project next time so the wait absorbs the startup — do not sequentialize',
|
|
377
|
+
'If a human is launching Godot, retry attach_project once they have launched — bridge.inject is idempotent',
|
|
378
|
+
'If Godot is already running but was launched before the bridge was injected, restart it (autoloads are read at startup)',
|
|
379
|
+
'Check that no other Godot project is occupying the bridge port (default 9900)',
|
|
380
|
+
]);
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
content: [
|
|
384
|
+
{
|
|
385
|
+
type: 'text',
|
|
386
|
+
text: [
|
|
387
|
+
'Project attached and MCP bridge is ready.',
|
|
388
|
+
'- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
|
|
389
|
+
'- get_debug_output is unavailable in attached mode because MCP did not spawn the process',
|
|
390
|
+
'- Use detach_project or stop_project when done to clean up the injected bridge state',
|
|
391
|
+
].join('\n'),
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
return createErrorResponse(`Failed to attach project: ${getErrorMessage(error)}`, [
|
|
398
|
+
'Check if project.godot is accessible',
|
|
399
|
+
'Ensure MCP can write the bridge autoload into the project',
|
|
400
|
+
]);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
export async function handleDetachProject(runner) {
|
|
404
|
+
if (runner.activeSessionMode !== 'attached') {
|
|
405
|
+
return createErrorResponse('No attached project to detach.', [
|
|
406
|
+
'Use attach_project first for manual-launch workflows',
|
|
407
|
+
'If MCP launched the game, use stop_project instead',
|
|
408
|
+
]);
|
|
409
|
+
}
|
|
410
|
+
const result = (await runner.stopProject());
|
|
411
|
+
return {
|
|
412
|
+
content: [
|
|
413
|
+
{
|
|
414
|
+
type: 'text',
|
|
415
|
+
text: JSON.stringify({
|
|
416
|
+
message: 'Detached attached project and cleaned MCP bridge state',
|
|
417
|
+
externalProcessPreserved: result.externalProcessPreserved === true,
|
|
418
|
+
}),
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
export function handleGetDebugOutput(runner, args = {}) {
|
|
424
|
+
args = normalizeParameters(args);
|
|
425
|
+
if (!runner.activeSessionMode) {
|
|
426
|
+
return createErrorResponse('No active runtime session.', [
|
|
427
|
+
'Use run_project to start a Godot project first',
|
|
428
|
+
'Or use attach_project before launching Godot manually',
|
|
429
|
+
]);
|
|
430
|
+
}
|
|
431
|
+
if (runner.activeSessionMode === 'attached') {
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: 'text',
|
|
436
|
+
text: JSON.stringify({
|
|
437
|
+
output: [],
|
|
438
|
+
errors: [],
|
|
439
|
+
running: null,
|
|
440
|
+
attached: true,
|
|
441
|
+
tip: 'Attached mode does not capture stdout/stderr because Godot was launched outside MCP.',
|
|
442
|
+
}),
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const proc = runner.activeProcess;
|
|
448
|
+
if (!proc) {
|
|
449
|
+
return createErrorResponse('No active spawned process is available for debug output.', [
|
|
450
|
+
'Use run_project to start a Godot project first',
|
|
451
|
+
'Or use attach_project only when stdout/stderr capture is not needed',
|
|
452
|
+
]);
|
|
453
|
+
}
|
|
454
|
+
const limit = typeof args.limit === 'number' ? args.limit : 200;
|
|
455
|
+
const response = {
|
|
456
|
+
output: proc.output.slice(-limit),
|
|
457
|
+
errors: proc.errors.slice(-limit),
|
|
458
|
+
running: !proc.hasExited,
|
|
459
|
+
};
|
|
460
|
+
if (proc.hasExited) {
|
|
461
|
+
response.exitCode = proc.exitCode;
|
|
462
|
+
response.tip =
|
|
463
|
+
'Process has exited. Call stop_project to clean up the process slot before starting a new one.';
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
content: [
|
|
467
|
+
{
|
|
468
|
+
type: 'text',
|
|
469
|
+
text: JSON.stringify(response),
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
export async function handleStopProject(runner) {
|
|
475
|
+
const result = await runner.stopProject();
|
|
476
|
+
if (!result) {
|
|
477
|
+
return createErrorResponse('No active Godot process to stop.', [
|
|
478
|
+
'Use run_project to start a Godot project first',
|
|
479
|
+
'The process may have already terminated',
|
|
480
|
+
]);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{
|
|
485
|
+
type: 'text',
|
|
486
|
+
text: JSON.stringify({
|
|
487
|
+
message: result.mode === 'attached'
|
|
488
|
+
? 'Attached project detached and MCP bridge state cleaned up'
|
|
489
|
+
: 'Godot project stopped',
|
|
490
|
+
mode: result.mode,
|
|
491
|
+
externalProcessPreserved: result.externalProcessPreserved === true,
|
|
492
|
+
finalOutput: result.output.slice(-200),
|
|
493
|
+
finalErrors: result.errors.slice(-200),
|
|
494
|
+
}),
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function parseScreenshotResponseMode(value) {
|
|
500
|
+
if (value === undefined)
|
|
501
|
+
return 'preview';
|
|
502
|
+
if (typeof value !== 'string')
|
|
503
|
+
return null;
|
|
504
|
+
return SCREENSHOT_RESPONSE_MODES.includes(value)
|
|
505
|
+
? value
|
|
506
|
+
: null;
|
|
507
|
+
}
|
|
508
|
+
function parsePreviewDimension(value, fallback) {
|
|
509
|
+
if (value === undefined)
|
|
510
|
+
return fallback;
|
|
511
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
512
|
+
return null;
|
|
513
|
+
return Math.max(1, Math.floor(value));
|
|
514
|
+
}
|
|
515
|
+
function normalizeScreenshotPath(path) {
|
|
516
|
+
return sep === '\\' ? path.replace(/\//g, '\\') : path;
|
|
517
|
+
}
|
|
518
|
+
export async function handleTakeScreenshot(runner, args) {
|
|
519
|
+
args = normalizeParameters(args);
|
|
520
|
+
const sessionError = ensureRuntimeSession(runner, 'take a screenshot');
|
|
521
|
+
if (sessionError) {
|
|
522
|
+
return sessionError;
|
|
523
|
+
}
|
|
524
|
+
const timeout = typeof args.timeout === 'number' ? args.timeout : 10000;
|
|
525
|
+
const responseMode = parseScreenshotResponseMode(args.responseMode);
|
|
526
|
+
if (responseMode === null) {
|
|
527
|
+
return createErrorResponse('Invalid responseMode for take_screenshot', [
|
|
528
|
+
'Use one of: "full", "preview", or "path_only"',
|
|
529
|
+
]);
|
|
530
|
+
}
|
|
531
|
+
const previewMaxWidth = parsePreviewDimension(args.previewMaxWidth, DEFAULT_PREVIEW_MAX_WIDTH);
|
|
532
|
+
const previewMaxHeight = parsePreviewDimension(args.previewMaxHeight, DEFAULT_PREVIEW_MAX_HEIGHT);
|
|
533
|
+
if (previewMaxWidth === null || previewMaxHeight === null) {
|
|
534
|
+
return createErrorResponse('Invalid preview dimensions for take_screenshot', [
|
|
535
|
+
'previewMaxWidth and previewMaxHeight must be positive numbers',
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
const commandParams = {};
|
|
539
|
+
if (responseMode === 'preview') {
|
|
540
|
+
commandParams.preview_max_width = previewMaxWidth;
|
|
541
|
+
commandParams.preview_max_height = previewMaxHeight;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('screenshot', commandParams, timeout);
|
|
545
|
+
const parsedResult = parseBridgeJson(responseStr, 'screenshot');
|
|
546
|
+
if (!parsedResult.ok)
|
|
547
|
+
return parsedResult.response;
|
|
548
|
+
const parsed = parsedResult.data;
|
|
549
|
+
if (parsed.error) {
|
|
550
|
+
return createErrorResponse(`Screenshot server error: ${parsed.error}`, [
|
|
551
|
+
'Ensure the project has a viewport (a headless project with no display server cannot render)',
|
|
552
|
+
'Check disk space and permissions on the project directory (.mcp/screenshots/)',
|
|
553
|
+
]);
|
|
554
|
+
}
|
|
555
|
+
if (!parsed.path) {
|
|
556
|
+
return createErrorResponse('Screenshot server returned no file path', [
|
|
557
|
+
'The bridge response is missing the expected `path` field — this is a bridge bug, not a timing issue',
|
|
558
|
+
'Check get_debug_output for runtime errors during the screenshot save',
|
|
559
|
+
]);
|
|
560
|
+
}
|
|
561
|
+
// Normalize path for the local filesystem (forward slashes from GDScript)
|
|
562
|
+
const screenshotPath = normalizeScreenshotPath(parsed.path);
|
|
563
|
+
if (!existsSync(screenshotPath)) {
|
|
564
|
+
return createErrorResponse(`Screenshot file not found at: ${screenshotPath}`, [
|
|
565
|
+
'The screenshot may have failed to save',
|
|
566
|
+
'Check disk space and permissions',
|
|
567
|
+
]);
|
|
568
|
+
}
|
|
569
|
+
const metadata = {
|
|
570
|
+
responseMode,
|
|
571
|
+
path: parsed.path,
|
|
572
|
+
size: { width: parsed.width, height: parsed.height },
|
|
573
|
+
};
|
|
574
|
+
const content = [];
|
|
575
|
+
if (responseMode === 'full') {
|
|
576
|
+
const imageBuffer = readFileSync(screenshotPath);
|
|
577
|
+
content.push({
|
|
578
|
+
type: 'image',
|
|
579
|
+
data: imageBuffer.toString('base64'),
|
|
580
|
+
mimeType: 'image/png',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
else if (responseMode === 'preview') {
|
|
584
|
+
if (!parsed.preview_path) {
|
|
585
|
+
return createErrorResponse('Screenshot server returned no preview path', [
|
|
586
|
+
'Ensure the running project has the current McpBridge autoload',
|
|
587
|
+
'Restart the runtime after rebuilding the MCP server',
|
|
588
|
+
]);
|
|
589
|
+
}
|
|
590
|
+
const previewPath = normalizeScreenshotPath(parsed.preview_path);
|
|
591
|
+
if (!existsSync(previewPath)) {
|
|
592
|
+
return createErrorResponse(`Screenshot preview file not found at: ${previewPath}`, [
|
|
593
|
+
'The preview may have failed to save',
|
|
594
|
+
'Try again, or use responseMode "full" to return the original screenshot',
|
|
595
|
+
]);
|
|
596
|
+
}
|
|
597
|
+
const previewBuffer = readFileSync(previewPath);
|
|
598
|
+
content.push({
|
|
599
|
+
type: 'image',
|
|
600
|
+
data: previewBuffer.toString('base64'),
|
|
601
|
+
mimeType: 'image/png',
|
|
602
|
+
});
|
|
603
|
+
metadata.previewPath = parsed.preview_path;
|
|
604
|
+
metadata.previewSize = { width: parsed.preview_width, height: parsed.preview_height };
|
|
605
|
+
}
|
|
606
|
+
attachRuntimeWarnings(metadata, runtimeErrors);
|
|
607
|
+
content.push({ type: 'text', text: JSON.stringify(metadata) });
|
|
608
|
+
return { content };
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
return createErrorResponse(`Failed to take screenshot: ${getErrorMessage(error)}`, [
|
|
612
|
+
'Check get_debug_output for crash backtraces or runtime errors',
|
|
613
|
+
'If the game has exited, call stop_project, then run_project again',
|
|
614
|
+
'For slow renders, increase the timeout parameter',
|
|
615
|
+
]);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
export async function handleSimulateInput(runner, args) {
|
|
619
|
+
args = normalizeParameters(args);
|
|
620
|
+
const sessionError = ensureRuntimeSession(runner, 'simulate input');
|
|
621
|
+
if (sessionError) {
|
|
622
|
+
return sessionError;
|
|
623
|
+
}
|
|
624
|
+
const actions = args.actions;
|
|
625
|
+
if (!Array.isArray(actions) || actions.length === 0) {
|
|
626
|
+
return createErrorResponse('actions must be a non-empty array of input actions', [
|
|
627
|
+
'Provide at least one action object with a "type" field',
|
|
628
|
+
]);
|
|
629
|
+
}
|
|
630
|
+
// Calculate timeout: sum of all wait durations + 10s buffer
|
|
631
|
+
let totalWaitMs = 0;
|
|
632
|
+
for (const action of actions) {
|
|
633
|
+
if (typeof action === 'object' &&
|
|
634
|
+
action !== null &&
|
|
635
|
+
action.type === 'wait' &&
|
|
636
|
+
typeof action.ms === 'number') {
|
|
637
|
+
totalWaitMs += action.ms;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const timeoutMs = totalWaitMs + 10000;
|
|
641
|
+
try {
|
|
642
|
+
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('input', { actions }, timeoutMs);
|
|
643
|
+
const parsedResult = parseBridgeJson(responseStr, 'simulate_input');
|
|
644
|
+
if (!parsedResult.ok)
|
|
645
|
+
return parsedResult.response;
|
|
646
|
+
const parsed = parsedResult.data;
|
|
647
|
+
if (parsed.error) {
|
|
648
|
+
return createErrorResponse(`Input simulation error: ${parsed.error}`, [
|
|
649
|
+
'Check action types and parameters',
|
|
650
|
+
'Ensure key names are valid Godot key names',
|
|
651
|
+
]);
|
|
652
|
+
}
|
|
653
|
+
const payload = {
|
|
654
|
+
success: true,
|
|
655
|
+
actions_processed: parsed.actions_processed,
|
|
656
|
+
tip: 'Call take_screenshot to verify the input had the intended visual effect.',
|
|
657
|
+
};
|
|
658
|
+
attachRuntimeWarnings(payload, runtimeErrors);
|
|
659
|
+
return {
|
|
660
|
+
content: [
|
|
661
|
+
{
|
|
662
|
+
type: 'text',
|
|
663
|
+
text: JSON.stringify(payload),
|
|
664
|
+
},
|
|
665
|
+
],
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
return createErrorResponse(`Failed to simulate input: ${getErrorMessage(error)}`, [
|
|
670
|
+
'Check get_debug_output for crash backtraces or runtime errors (a signal handler firing on input may have crashed the game)',
|
|
671
|
+
'If the game has exited, call stop_project, then run_project again',
|
|
672
|
+
]);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
export async function handleGetUiElements(runner, args) {
|
|
676
|
+
args = normalizeParameters(args);
|
|
677
|
+
const sessionError = ensureRuntimeSession(runner, 'query UI elements');
|
|
678
|
+
if (sessionError) {
|
|
679
|
+
return sessionError;
|
|
680
|
+
}
|
|
681
|
+
const visibleOnly = args.visibleOnly !== false;
|
|
682
|
+
try {
|
|
683
|
+
const cmdParams = { visible_only: visibleOnly };
|
|
684
|
+
if (args.filter)
|
|
685
|
+
cmdParams.type_filter = args.filter;
|
|
686
|
+
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('get_ui_elements', cmdParams);
|
|
687
|
+
const parsedResult = parseBridgeJson(responseStr, 'get_ui_elements');
|
|
688
|
+
if (!parsedResult.ok)
|
|
689
|
+
return parsedResult.response;
|
|
690
|
+
const parsed = parsedResult.data;
|
|
691
|
+
if (parsed.error) {
|
|
692
|
+
return createErrorResponse(`UI element query error: ${parsed.error}`, [
|
|
693
|
+
'Ensure the game has a UI with Control nodes',
|
|
694
|
+
]);
|
|
695
|
+
}
|
|
696
|
+
const payload = {
|
|
697
|
+
...parsed,
|
|
698
|
+
tip: "Use simulate_input with type 'click_element' and a node_path or node name from this list to interact with these elements.",
|
|
699
|
+
};
|
|
700
|
+
attachRuntimeWarnings(payload, runtimeErrors);
|
|
701
|
+
return {
|
|
702
|
+
content: [
|
|
703
|
+
{
|
|
704
|
+
type: 'text',
|
|
705
|
+
text: JSON.stringify(payload),
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
return createErrorResponse(`Failed to get UI elements: ${getErrorMessage(error)}`, [
|
|
712
|
+
'Check get_debug_output for crash backtraces or runtime errors',
|
|
713
|
+
'If the game has exited, call stop_project, then run_project again',
|
|
714
|
+
]);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
export async function handleRunScript(runner, args) {
|
|
718
|
+
args = normalizeParameters(args);
|
|
719
|
+
const sessionError = ensureRuntimeSession(runner, 'execute scripts');
|
|
720
|
+
if (sessionError) {
|
|
721
|
+
return sessionError;
|
|
722
|
+
}
|
|
723
|
+
const script = args.script;
|
|
724
|
+
if (typeof script !== 'string' || script.trim() === '') {
|
|
725
|
+
return createErrorResponse('script is required and must be a non-empty string', [
|
|
726
|
+
'Provide GDScript source code with extends RefCounted and func execute(scene_tree: SceneTree) -> Variant',
|
|
727
|
+
]);
|
|
728
|
+
}
|
|
729
|
+
if (!script.includes('func execute')) {
|
|
730
|
+
return createErrorResponse('Script must define func execute(scene_tree: SceneTree) -> Variant', ['Add a func execute(scene_tree: SceneTree) -> Variant method to your script']);
|
|
731
|
+
}
|
|
732
|
+
// Write script to .mcp/scripts/ for audit trail
|
|
733
|
+
try {
|
|
734
|
+
const projectPath = runner.activeProjectPath;
|
|
735
|
+
if (projectPath) {
|
|
736
|
+
const scriptsDir = join(projectPath, '.mcp', 'scripts');
|
|
737
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
738
|
+
const timestamp = Date.now();
|
|
739
|
+
const scriptFile = join(scriptsDir, `${timestamp}-${randomUUID()}.gd`);
|
|
740
|
+
writeFileSync(scriptFile, script, 'utf8');
|
|
741
|
+
logDebug(`Saved script to ${scriptFile}`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
logDebug(`Failed to save script for audit: ${error}`);
|
|
746
|
+
}
|
|
747
|
+
const timeout = typeof args.timeout === 'number' ? args.timeout : 30000;
|
|
748
|
+
try {
|
|
749
|
+
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('run_script', { source: script }, timeout);
|
|
750
|
+
const parsedResult = parseBridgeJson(responseStr, 'run_script');
|
|
751
|
+
if (!parsedResult.ok)
|
|
752
|
+
return parsedResult.response;
|
|
753
|
+
const parsed = parsedResult.data;
|
|
754
|
+
if (parsed.error) {
|
|
755
|
+
return createErrorResponse(`Script execution error: ${parsed.error}`, [
|
|
756
|
+
'Check your GDScript syntax',
|
|
757
|
+
'Ensure the script extends RefCounted',
|
|
758
|
+
'Check get_debug_output for details',
|
|
759
|
+
]);
|
|
760
|
+
}
|
|
761
|
+
// Detect false-positive success: GDScript has no try-catch, so runtime errors
|
|
762
|
+
// return null and the real error only appears in stderr.
|
|
763
|
+
if (parsed.success && parsed.result === null && runner.activeSessionMode === 'spawned') {
|
|
764
|
+
if (runtimeErrors.length > 0) {
|
|
765
|
+
const errorContext = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES).join('\n');
|
|
766
|
+
return createErrorResponse(`Script runtime error detected:\n${errorContext}`, [
|
|
767
|
+
'Fix the GDScript error in your script and retry',
|
|
768
|
+
'Use get_debug_output for full process output',
|
|
769
|
+
]);
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
content: [
|
|
773
|
+
{
|
|
774
|
+
type: 'text',
|
|
775
|
+
text: JSON.stringify({
|
|
776
|
+
success: true,
|
|
777
|
+
result: null,
|
|
778
|
+
warning: 'Script returned null. If unexpected, check get_debug_output for runtime errors — GDScript does not propagate exceptions.',
|
|
779
|
+
tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
|
|
780
|
+
}),
|
|
781
|
+
},
|
|
782
|
+
],
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
const payload = {
|
|
786
|
+
success: true,
|
|
787
|
+
result: parsed.result,
|
|
788
|
+
tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
|
|
789
|
+
};
|
|
790
|
+
attachRuntimeWarnings(payload, runtimeErrors);
|
|
791
|
+
return {
|
|
792
|
+
content: [
|
|
793
|
+
{
|
|
794
|
+
type: 'text',
|
|
795
|
+
text: JSON.stringify(payload),
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
return createErrorResponse(`Failed to execute script: ${getErrorMessage(error)}`, [
|
|
802
|
+
'Check get_debug_output for crash backtraces or runtime errors raised inside the script',
|
|
803
|
+
'If the game has exited, call stop_project, then run_project again',
|
|
804
|
+
'For long-running scripts, increase the timeout parameter',
|
|
805
|
+
]);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
//# sourceMappingURL=runtime-tools.js.map
|