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.
Files changed (59) hide show
  1. package/README.md +30 -93
  2. package/dist/dispatch.d.ts +1 -11
  3. package/dist/dispatch.d.ts.map +1 -1
  4. package/dist/dispatch.js +7 -8
  5. package/dist/dispatch.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +12 -10
  9. package/dist/index.js.map +1 -1
  10. package/dist/scripts/godot_operations.gd +134 -283
  11. package/dist/scripts/mcp_bridge.gd +210 -43
  12. package/dist/tools/autoload-tools.d.ts +51 -0
  13. package/dist/tools/autoload-tools.d.ts.map +1 -0
  14. package/dist/tools/autoload-tools.js +187 -0
  15. package/dist/tools/autoload-tools.js.map +1 -0
  16. package/dist/tools/node-tools.d.ts +9 -78
  17. package/dist/tools/node-tools.d.ts.map +1 -1
  18. package/dist/tools/node-tools.js +114 -310
  19. package/dist/tools/node-tools.js.map +1 -1
  20. package/dist/tools/project-tools.d.ts +0 -168
  21. package/dist/tools/project-tools.d.ts.map +1 -1
  22. package/dist/tools/project-tools.js +120 -1192
  23. package/dist/tools/project-tools.js.map +1 -1
  24. package/dist/tools/runtime-tools.d.ts +108 -0
  25. package/dist/tools/runtime-tools.d.ts.map +1 -0
  26. package/dist/tools/runtime-tools.js +808 -0
  27. package/dist/tools/runtime-tools.js.map +1 -0
  28. package/dist/tools/scene-tools.d.ts +6 -48
  29. package/dist/tools/scene-tools.d.ts.map +1 -1
  30. package/dist/tools/scene-tools.js +67 -211
  31. package/dist/tools/scene-tools.js.map +1 -1
  32. package/dist/tools/validate-tools.d.ts.map +1 -1
  33. package/dist/tools/validate-tools.js +35 -29
  34. package/dist/tools/validate-tools.js.map +1 -1
  35. package/dist/utils/autoload-ini.d.ts +32 -0
  36. package/dist/utils/autoload-ini.d.ts.map +1 -0
  37. package/dist/utils/autoload-ini.js +111 -0
  38. package/dist/utils/autoload-ini.js.map +1 -0
  39. package/dist/utils/bridge-manager.d.ts +29 -0
  40. package/dist/utils/bridge-manager.d.ts.map +1 -0
  41. package/dist/utils/bridge-manager.js +136 -0
  42. package/dist/utils/bridge-manager.js.map +1 -0
  43. package/dist/utils/bridge-protocol.d.ts +34 -0
  44. package/dist/utils/bridge-protocol.d.ts.map +1 -0
  45. package/dist/utils/bridge-protocol.js +65 -0
  46. package/dist/utils/bridge-protocol.js.map +1 -0
  47. package/dist/utils/godot-runner.d.ts +70 -15
  48. package/dist/utils/godot-runner.d.ts.map +1 -1
  49. package/dist/utils/godot-runner.js +309 -277
  50. package/dist/utils/godot-runner.js.map +1 -1
  51. package/dist/utils/handler-helpers.d.ts +34 -0
  52. package/dist/utils/handler-helpers.d.ts.map +1 -0
  53. package/dist/utils/handler-helpers.js +55 -0
  54. package/dist/utils/handler-helpers.js.map +1 -0
  55. package/dist/utils/logger.d.ts +3 -0
  56. package/dist/utils/logger.d.ts.map +1 -0
  57. package/dist/utils/logger.js +11 -0
  58. package/dist/utils/logger.js.map +1 -0
  59. package/package.json +7 -4
@@ -1,186 +1,13 @@
1
- import { join, basename, sep } from 'path';
2
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
- import { normalizeParameters, validatePath, validateProjectArgs, createErrorResponse, logDebug, } from '../utils/godot-runner.js';
4
- const MAX_RUNTIME_ERROR_CONTEXT_LINES = 30;
5
- function parseAutoloads(projectFilePath) {
6
- const content = readFileSync(projectFilePath, 'utf8');
7
- const autoloads = [];
8
- let inAutoloadSection = false;
9
- for (const line of content.split('\n')) {
10
- const trimmed = line.trim();
11
- if (trimmed.startsWith('[')) {
12
- inAutoloadSection = trimmed === '[autoload]';
13
- continue;
14
- }
15
- if (!inAutoloadSection || trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('#'))
16
- continue;
17
- const match = trimmed.match(/^(\w+)="(\*?)([^"]*)"$/);
18
- if (match) {
19
- autoloads.push({ name: match[1], singleton: match[2] === '*', path: match[3] });
20
- }
21
- }
22
- return autoloads;
23
- }
24
- function normalizeAutoloadPath(p) {
25
- return p.startsWith('res://') ? p : `res://${p}`;
26
- }
27
- function addAutoloadEntry(projectFilePath, name, path, singleton) {
28
- const content = readFileSync(projectFilePath, 'utf8');
29
- const lines = content.split('\n');
30
- const entry = `${name}="${singleton ? '*' : ''}${normalizeAutoloadPath(path)}"`;
31
- const sectionIdx = lines.findIndex((l) => l.trim() === '[autoload]');
32
- if (sectionIdx === -1) {
33
- writeFileSync(projectFilePath, content.trimEnd() + '\n\n[autoload]\n' + entry + '\n', 'utf8');
34
- return;
35
- }
36
- let insertIdx = sectionIdx + 1;
37
- while (insertIdx < lines.length && !lines[insertIdx].trim().startsWith('[')) {
38
- insertIdx++;
39
- }
40
- lines.splice(insertIdx, 0, entry);
41
- writeFileSync(projectFilePath, lines.join('\n'), 'utf8');
42
- }
43
- function removeAutoloadEntry(projectFilePath, name) {
44
- const content = readFileSync(projectFilePath, 'utf8');
45
- const lines = content.split('\n');
46
- let inAutoloadSection = false;
47
- let removed = false;
48
- const newLines = lines.filter((line) => {
49
- const trimmed = line.trim();
50
- if (trimmed.startsWith('[')) {
51
- inAutoloadSection = trimmed === '[autoload]';
52
- return true;
53
- }
54
- if (inAutoloadSection) {
55
- const match = trimmed.match(/^(\w+)=/);
56
- if (match && match[1] === name) {
57
- removed = true;
58
- return false;
59
- }
60
- }
61
- return true;
62
- });
63
- if (removed)
64
- writeFileSync(projectFilePath, newLines.join('\n'), 'utf8');
65
- return removed;
66
- }
67
- function updateAutoloadEntry(projectFilePath, name, newPath, singleton) {
68
- const content = readFileSync(projectFilePath, 'utf8');
69
- const lines = content.split('\n');
70
- let inAutoloadSection = false;
71
- let updated = false;
72
- const newLines = lines.map((line) => {
73
- const trimmed = line.trim();
74
- if (trimmed.startsWith('[')) {
75
- inAutoloadSection = trimmed === '[autoload]';
76
- return line;
77
- }
78
- if (inAutoloadSection) {
79
- const match = trimmed.match(/^(\w+)="(\*?)([^"]*)"$/);
80
- if (match && match[1] === name) {
81
- const effectiveSingleton = singleton !== undefined ? singleton : match[2] === '*';
82
- const effectivePath = newPath !== undefined ? normalizeAutoloadPath(newPath) : match[3];
83
- updated = true;
84
- return `${name}="${effectiveSingleton ? '*' : ''}${effectivePath}"`;
85
- }
86
- }
87
- return line;
88
- });
89
- if (updated)
90
- writeFileSync(projectFilePath, newLines.join('\n'), 'utf8');
91
- return updated;
92
- }
1
+ import { join, basename } from 'path';
2
+ import { existsSync, readdirSync, readFileSync } from 'fs';
3
+ import { normalizeParameters, validatePath, validateProjectArgs, createErrorResponse, getErrorMessage, projectGodotPath, } from '../utils/godot-runner.js';
4
+ import { logDebug } from '../utils/logger.js';
93
5
  // --- Tool definitions ---
94
6
  export const projectToolDefinitions = [
95
- {
96
- name: 'launch_editor',
97
- description: 'Open the Godot editor GUI for a project. The editor is a display application — it cannot be controlled programmatically and returns immediately. For headless project modification, use the scene and node editing tools (add_node, set_node_property, etc.) instead.',
98
- inputSchema: {
99
- type: 'object',
100
- properties: {
101
- projectPath: {
102
- type: 'string',
103
- description: 'Path to the Godot project directory',
104
- },
105
- },
106
- required: ['projectPath'],
107
- },
108
- },
109
- {
110
- name: 'run_project',
111
- description: 'Run a Godot project with stdout/stderr captured. Preferred path for runtime tools. Required before calling take_screenshot, simulate_input, get_ui_elements, run_script, or get_debug_output unless you intentionally use attach_project for a manually launched game. Verifies MCP bridge readiness before returning success. Call stop_project when done.',
112
- inputSchema: {
113
- type: 'object',
114
- properties: {
115
- projectPath: {
116
- type: 'string',
117
- description: 'Path to the Godot project directory',
118
- },
119
- scene: {
120
- type: 'string',
121
- description: 'Scene to run (path relative to project, e.g. "scenes/main.tscn"). Omit to use the project\'s main scene.',
122
- },
123
- background: {
124
- type: 'boolean',
125
- 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.',
126
- },
127
- },
128
- required: ['projectPath'],
129
- },
130
- },
131
- {
132
- name: 'attach_project',
133
- description: 'Attach runtime MCP tools to a project without spawning Godot. This injects the McpBridge autoload and marks the project as the active runtime session so you can launch the game manually from your own shell, then use take_screenshot, simulate_input, get_ui_elements, and run_script against that running game. Set waitForBridge to true after launching Godot to block until the bridge is confirmed ready. Use detach_project or stop_project when done. get_debug_output is not available in attached mode because stdout/stderr are not captured.',
134
- inputSchema: {
135
- type: 'object',
136
- properties: {
137
- projectPath: {
138
- type: 'string',
139
- description: 'Path to the Godot project directory',
140
- },
141
- waitForBridge: {
142
- type: 'boolean',
143
- description: 'If true, poll the bridge until it responds (up to 15 seconds). Use this after Godot is already running to confirm runtime tools are ready. Defaults to false.',
144
- },
145
- },
146
- required: ['projectPath'],
147
- },
148
- },
149
- {
150
- name: 'detach_project',
151
- description: 'Clear attached-mode runtime state and remove the injected McpBridge autoload without claiming that the manually launched Godot process was stopped.',
152
- inputSchema: {
153
- type: 'object',
154
- properties: {},
155
- required: [],
156
- },
157
- },
158
- {
159
- name: 'get_debug_output',
160
- description: 'Get stdout/stderr output from the running Godot project. Requires run_project first. Returns the last N lines of output and errors, a running flag, and an exit code if the process has ended. In attached mode, this reports that stdout/stderr capture is unavailable because Godot was launched outside MCP.',
161
- inputSchema: {
162
- type: 'object',
163
- properties: {
164
- limit: {
165
- type: 'number',
166
- description: 'Max lines to return (default: 200, from end of output)',
167
- },
168
- },
169
- required: [],
170
- },
171
- },
172
- {
173
- name: 'stop_project',
174
- description: 'Stop the running Godot project and clean up the MCP bridge. Always call this when done with runtime testing — even if the game crashed — to free the process slot so run_project can be called again.',
175
- inputSchema: {
176
- type: 'object',
177
- properties: {},
178
- required: [],
179
- },
180
- },
181
7
  {
182
8
  name: 'list_projects',
183
- description: 'List Godot projects in a directory',
9
+ description: 'Find Godot projects under a directory by locating project.godot files. Use to discover available projects when the user has not specified one; for inspecting a known project, use get_project_info. recursive:true descends into subdirectories (skipping hidden ones); default false checks only the directory itself and its immediate children. Returns { projects: [{ path, name }] }, empty array on no matches.',
10
+ annotations: { readOnlyHint: true },
184
11
  inputSchema: {
185
12
  type: 'object',
186
13
  properties: {
@@ -198,7 +25,8 @@ export const projectToolDefinitions = [
198
25
  },
199
26
  {
200
27
  name: 'get_project_info',
201
- description: 'Retrieve metadata about a Godot project, or just the Godot version if no projectPath is provided',
28
+ description: 'Get metadata about a Godot project: name, path, Godot version, and a structure summary (counts of scenes/scripts/assets/other). Omit projectPath to get just the Godot version (useful for capability checks). Returns { name, path, godotVersion, structure } or { godotVersion } when projectPath is omitted. Errors if projectPath is set but lacks project.godot.',
29
+ annotations: { readOnlyHint: true },
202
30
  inputSchema: {
203
31
  type: 'object',
204
32
  properties: {
@@ -210,196 +38,10 @@ export const projectToolDefinitions = [
210
38
  required: [],
211
39
  },
212
40
  },
213
- {
214
- name: 'take_screenshot',
215
- description: 'Capture a PNG screenshot of the running Godot viewport. Requires an active runtime session (run_project or attach_project). Returns the image inline. Screenshots are also saved to .mcp/screenshots/ in the project directory.',
216
- inputSchema: {
217
- type: 'object',
218
- properties: {
219
- timeout: {
220
- type: 'number',
221
- description: 'Timeout in milliseconds to wait for the screenshot (default: 10000)',
222
- },
223
- },
224
- required: [],
225
- },
226
- },
227
- {
228
- name: 'simulate_input',
229
- description: 'Simulate batched sequential input in a running Godot project. Requires an active runtime session (run_project or attach_project). Use get_ui_elements first to discover element names and paths for click_element actions.\n\nEach action object requires a "type" field. Valid types and their specific fields:\n- key: keyboard event (key: string, pressed: bool, shift/ctrl/alt: bool)\n- mouse_button: click at coordinates (x, y: number, button: "left"|"right"|"middle", pressed: bool, double_click: bool)\n- mouse_motion: move cursor (x, y: number, relative_x, relative_y: number)\n- click_element: click a UI element by node path or node name (element: string, button, double_click)\n- action: fire a Godot input action (action: string, pressed: bool, strength: 0–1)\n- wait: pause between actions (ms: number)\n\nExamples:\n1. Press and release Space: [{type:"key",key:"Space",pressed:true},{type:"wait",ms:100},{type:"key",key:"Space",pressed:false}]\n2. Click a UI button (discover path with get_ui_elements first): [{type:"click_element",element:"StartButton"}]\n3. Left-click at viewport coordinates: [{type:"mouse_button",x:400,y:300,button:"left",pressed:true},{type:"mouse_button",x:400,y:300,button:"left",pressed:false}]\n4. Fire a Godot action: [{type:"action",action:"jump",pressed:true},{type:"wait",ms:200},{type:"action",action:"jump",pressed:false}]\n5. Type "hello": [{type:"key",key:"H",pressed:true},{type:"key",key:"H",pressed:false},{type:"key",key:"E",pressed:true},{type:"key",key:"E",pressed:false},{type:"key",key:"L",pressed:true},{type:"key",key:"L",pressed:false},{type:"key",key:"L",pressed:true},{type:"key",key:"L",pressed:false},{type:"key",key:"O",pressed:true},{type:"key",key:"O",pressed:false}]',
230
- inputSchema: {
231
- type: 'object',
232
- properties: {
233
- actions: {
234
- type: 'array',
235
- description: 'Array of input actions to execute sequentially. Each object must have a "type" field.',
236
- items: {
237
- type: 'object',
238
- properties: {
239
- type: {
240
- type: 'string',
241
- enum: ['key', 'mouse_button', 'mouse_motion', 'click_element', 'action', 'wait'],
242
- description: 'The type of input action',
243
- },
244
- key: {
245
- type: 'string',
246
- description: '[key] Key name (e.g. "W", "Space", "Escape", "Up")',
247
- },
248
- pressed: {
249
- type: 'boolean',
250
- description: '[key, mouse_button, action] Whether the input is pressed (true) or released (false)',
251
- },
252
- shift: { type: 'boolean', description: '[key] Shift modifier' },
253
- ctrl: { type: 'boolean', description: '[key] Ctrl modifier' },
254
- alt: { type: 'boolean', description: '[key] Alt modifier' },
255
- button: {
256
- type: 'string',
257
- enum: ['left', 'right', 'middle'],
258
- description: '[mouse_button, click_element] Mouse button (default: left)',
259
- },
260
- x: {
261
- type: 'number',
262
- description: '[mouse_button, mouse_motion] X position in viewport pixels',
263
- },
264
- y: {
265
- type: 'number',
266
- description: '[mouse_button, mouse_motion] Y position in viewport pixels',
267
- },
268
- relative_x: {
269
- type: 'number',
270
- description: '[mouse_motion] Relative X movement in pixels',
271
- },
272
- relative_y: {
273
- type: 'number',
274
- description: '[mouse_motion] Relative Y movement in pixels',
275
- },
276
- double_click: {
277
- type: 'boolean',
278
- description: '[mouse_button, click_element] Double click',
279
- },
280
- element: {
281
- type: 'string',
282
- 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.',
283
- },
284
- action: {
285
- type: 'string',
286
- description: '[action] Godot input action name (as defined in Project Settings > Input Map)',
287
- },
288
- strength: {
289
- type: 'number',
290
- description: '[action] Action strength (0–1, default 1.0)',
291
- },
292
- ms: {
293
- type: 'number',
294
- description: '[wait] Duration in milliseconds to pause before the next action',
295
- },
296
- },
297
- required: ['type'],
298
- },
299
- },
300
- },
301
- required: ['actions'],
302
- },
303
- },
304
- {
305
- name: 'get_ui_elements',
306
- description: 'Get Control nodes from a running Godot project with their positions, sizes, types, and text. Requires an active runtime session (run_project or attach_project). Call this before simulate_input with click_element to discover valid element names and paths. Returns: { elements: [{ name, path, type, rect: {x,y,width,height}, visible, text? (Button/Label/LineEdit/TextEdit), disabled? (buttons), tooltip? }] }',
307
- inputSchema: {
308
- type: 'object',
309
- properties: {
310
- visibleOnly: {
311
- type: 'boolean',
312
- description: 'Only return nodes where Control.visible is true (default: true). Set false to include hidden elements.',
313
- },
314
- filter: {
315
- type: 'string',
316
- description: 'Filter by Control node type (e.g. "Button", "Label", "LineEdit")',
317
- },
318
- },
319
- required: [],
320
- },
321
- },
322
- {
323
- name: 'run_script',
324
- description: 'Execute a custom GDScript in the live running project with full scene tree access. Requires run_project first. 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 are supported). Use print() for debug output — it appears in get_debug_output, not in the script result. In spawned mode, runtime errors emitted to stderr are detected and either escalated (when the script returns null) or surfaced as warnings. In attached mode a null result includes a caveat since stderr is not captured.',
325
- inputSchema: {
326
- type: 'object',
327
- properties: {
328
- script: {
329
- type: 'string',
330
- description: 'GDScript source code. Must contain "extends RefCounted" and "func execute(scene_tree: SceneTree) -> Variant".',
331
- },
332
- timeout: {
333
- type: 'number',
334
- description: 'Timeout in ms (default: 30000). Increase for long-running scripts.',
335
- },
336
- },
337
- required: ['script'],
338
- },
339
- },
340
- {
341
- name: 'list_autoloads',
342
- description: 'List all registered autoloads in a Godot project with paths and singleton status. No Godot process required — reads project.godot directly.',
343
- inputSchema: {
344
- type: 'object',
345
- properties: {
346
- projectPath: { type: 'string', description: 'Path to the Godot project directory' },
347
- },
348
- required: ['projectPath'],
349
- },
350
- },
351
- {
352
- name: 'add_autoload',
353
- description: 'Register a new autoload in a Godot project. No Godot process required. Warning: autoloads initialize in headless mode — if the script has errors, all headless operations will fail.',
354
- inputSchema: {
355
- type: 'object',
356
- properties: {
357
- projectPath: { type: 'string', description: 'Path to the Godot project directory' },
358
- autoloadName: {
359
- type: 'string',
360
- description: 'Name of the autoload node (e.g. "MyManager")',
361
- },
362
- autoloadPath: {
363
- type: 'string',
364
- description: 'Path to the script or scene (e.g. "res://autoload/my_manager.gd" or "autoload/my_manager.gd")',
365
- },
366
- singleton: {
367
- type: 'boolean',
368
- description: 'Register as a globally accessible singleton by name (default: true)',
369
- },
370
- },
371
- required: ['projectPath', 'autoloadName', 'autoloadPath'],
372
- },
373
- },
374
- {
375
- name: 'remove_autoload',
376
- description: 'Unregister an autoload from a Godot project by name. No Godot process required.',
377
- inputSchema: {
378
- type: 'object',
379
- properties: {
380
- projectPath: { type: 'string', description: 'Path to the Godot project directory' },
381
- autoloadName: { type: 'string', description: 'Name of the autoload to remove' },
382
- },
383
- required: ['projectPath', 'autoloadName'],
384
- },
385
- },
386
- {
387
- name: 'update_autoload',
388
- description: "Modify an existing autoload's path or singleton flag. No Godot process required.",
389
- inputSchema: {
390
- type: 'object',
391
- properties: {
392
- projectPath: { type: 'string', description: 'Path to the Godot project directory' },
393
- autoloadName: { type: 'string', description: 'Name of the autoload to update' },
394
- autoloadPath: { type: 'string', description: 'New path to the script or scene' },
395
- singleton: { type: 'boolean', description: 'New singleton flag' },
396
- },
397
- required: ['projectPath', 'autoloadName'],
398
- },
399
- },
400
41
  {
401
42
  name: 'get_project_files',
402
- description: 'Return a recursive file tree of a Godot project. Skips hidden (dot-prefixed) entries and the .mcp directory. Returns nested { name, type, path, extension?, children? } objects.',
43
+ description: 'Return a recursive file tree of a Godot project. Use to discover project structure when paths are unknown. Pass extensions to filter (e.g. ["gd","tscn"]); maxDepth caps recursion (-1 unlimited). Skips hidden (dot-prefixed) entries and the .mcp directory. Returns nested { name, type, path, extension?, children? } file tree.',
44
+ annotations: { readOnlyHint: true },
403
45
  inputSchema: {
404
46
  type: 'object',
405
47
  properties: {
@@ -419,7 +61,8 @@ export const projectToolDefinitions = [
419
61
  },
420
62
  {
421
63
  name: 'search_project',
422
- description: 'Plain-text search across project files. Returns { matches: [{ file, lineNumber, line }], truncated }. Skips hidden entries and the .mcp directory.',
64
+ description: 'Plain-text (substring) search across project files. Use to find references, callers, or signatures across the codebase. Default fileTypes is ["gd","tscn","cs","gdshader"]; caseSensitive default false; maxResults default 100 (truncated:true if hit). Returns { matches: [{ file, lineNumber, line }], truncated }. Skips hidden entries and the .mcp directory.',
65
+ annotations: { readOnlyHint: true },
423
66
  inputSchema: {
424
67
  type: 'object',
425
68
  properties: {
@@ -438,7 +81,8 @@ export const projectToolDefinitions = [
438
81
  },
439
82
  {
440
83
  name: 'get_scene_dependencies',
441
- description: 'Parse a .tscn file for ext_resource references (scripts, textures, subscenes). Returns { scene, dependencies: [{ path, type, uid? }] }.',
84
+ description: 'Parse a .tscn file for ext_resource references (scripts, textures, subscenes). Use to inspect what a scene depends on before refactoring or moving files. Returns { scene, dependencies: [{ path, type, uid? }] }. Errors if scene file does not exist.',
85
+ annotations: { readOnlyHint: true },
442
86
  inputSchema: {
443
87
  type: 'object',
444
88
  properties: {
@@ -453,7 +97,8 @@ export const projectToolDefinitions = [
453
97
  },
454
98
  {
455
99
  name: 'get_project_settings',
456
- description: 'Parse project.godot into structured JSON. Returns { settings: { [section]: { [key]: value } } }. Complex Godot types are returned as raw strings. Keys not under any section appear under __global__.',
100
+ description: 'Parse project.godot into structured JSON. Use to inspect configured display, input, rendering, etc. settings without launching Godot. Pass section to filter to one INI section (e.g. "display", "application"). Returns { settings: { [section]: { [key]: value } } } or { settings: { [key]: value } } when section is given. Complex Godot types are returned as raw strings; keys outside any section appear under __global__.',
101
+ annotations: { readOnlyHint: true },
457
102
  inputSchema: {
458
103
  type: 'object',
459
104
  properties: {
@@ -467,26 +112,12 @@ export const projectToolDefinitions = [
467
112
  },
468
113
  },
469
114
  ];
470
- function ensureRuntimeSession(runner, actionDescription) {
471
- if (!runner.activeSessionMode || !runner.activeProjectPath) {
472
- return createErrorResponse(`No active runtime session. A project must be running or attached to ${actionDescription}.`, [
473
- 'Use run_project to start a Godot project first',
474
- 'Or use attach_project before launching Godot manually',
475
- ]);
476
- }
477
- if (runner.activeSessionMode === 'spawned' &&
478
- (!runner.activeProcess || runner.activeProcess.hasExited)) {
479
- return createErrorResponse(`The spawned Godot process has exited and cannot ${actionDescription}.`, [
480
- 'Use get_debug_output to inspect the last captured logs',
481
- 'Call stop_project to clean up, then run_project again',
482
- ]);
483
- }
484
- return null;
485
- }
115
+ // --- Helpers ---
116
+ const PROJECT_SCAN_BLACKLIST = new Set(['.git', '.godot', '.mcp', 'node_modules', '.svn', '.hg']);
486
117
  function findGodotProjects(directory, recursive) {
487
118
  const projects = [];
488
119
  try {
489
- const projectFile = join(directory, 'project.godot');
120
+ const projectFile = projectGodotPath(directory);
490
121
  if (existsSync(projectFile)) {
491
122
  projects.push({
492
123
  path: directory,
@@ -495,10 +126,10 @@ function findGodotProjects(directory, recursive) {
495
126
  }
496
127
  const entries = readdirSync(directory, { withFileTypes: true });
497
128
  for (const entry of entries) {
498
- if (!entry.isDirectory() || entry.name.startsWith('.'))
129
+ if (!entry.isDirectory() || PROJECT_SCAN_BLACKLIST.has(entry.name))
499
130
  continue;
500
131
  const subdir = join(directory, entry.name);
501
- if (existsSync(join(subdir, 'project.godot'))) {
132
+ if (existsSync(projectGodotPath(subdir))) {
502
133
  projects.push({ path: subdir, name: entry.name });
503
134
  }
504
135
  else if (recursive) {
@@ -530,7 +161,8 @@ function getProjectStructure(projectPath) {
530
161
  scanDirectory(entryPath);
531
162
  }
532
163
  else if (entry.isFile()) {
533
- const ext = entry.name.split('.').pop()?.toLowerCase();
164
+ const dotIdx = entry.name.lastIndexOf('.');
165
+ const ext = dotIdx >= 0 ? entry.name.slice(dotIdx + 1).toLowerCase() : '';
534
166
  if (ext === 'tscn') {
535
167
  structure.scenes++;
536
168
  }
@@ -553,713 +185,51 @@ function getProjectStructure(projectPath) {
553
185
  scanDirectory(projectPath);
554
186
  return structure;
555
187
  }
556
- export async function handleLaunchEditor(runner, args) {
557
- args = normalizeParameters(args);
558
- if (!args.projectPath) {
559
- return createErrorResponse('Project path is required', [
560
- 'Provide a valid path to a Godot project directory',
561
- ]);
562
- }
563
- if (!validatePath(args.projectPath)) {
564
- return createErrorResponse('Invalid project path', [
565
- 'Provide a valid path without ".." or other potentially unsafe characters',
566
- ]);
567
- }
568
- try {
569
- if (!runner.getGodotPath()) {
570
- await runner.detectGodotPath();
571
- if (!runner.getGodotPath()) {
572
- return createErrorResponse('Could not find a valid Godot executable path', [
573
- 'Ensure Godot is installed correctly',
574
- 'Set GODOT_PATH environment variable',
575
- ]);
576
- }
577
- }
578
- const projectFile = join(args.projectPath, 'project.godot');
579
- if (!existsSync(projectFile)) {
580
- return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
581
- 'Ensure the path points to a directory containing a project.godot file',
582
- 'Use list_projects to find valid Godot projects',
583
- ]);
584
- }
585
- logDebug(`Launching Godot editor for project: ${args.projectPath}`);
586
- const process = runner.launchEditor(args.projectPath);
587
- process.on('error', (err) => {
588
- console.error('Failed to start Godot editor:', err);
589
- });
590
- return {
591
- content: [
592
- {
593
- type: 'text',
594
- text: `Godot editor launched successfully for project at ${args.projectPath}.\nNote: the editor is a GUI application and cannot be controlled programmatically. Use the scene and node editing tools (add_node, set_node_property, etc.) to modify the project headlessly without the editor.`,
595
- },
596
- ],
597
- };
598
- }
599
- catch (error) {
600
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
601
- return createErrorResponse(`Failed to launch Godot editor: ${errorMessage}`, [
602
- 'Ensure Godot is installed correctly',
603
- 'Check if the GODOT_PATH environment variable is set correctly',
604
- ]);
605
- }
606
- }
607
- export async function handleRunProject(runner, args) {
608
- args = normalizeParameters(args);
609
- if (!args.projectPath) {
610
- return createErrorResponse('Project path is required', [
611
- 'Provide a valid path to a Godot project directory',
612
- ]);
613
- }
614
- if (!validatePath(args.projectPath)) {
615
- return createErrorResponse('Invalid project path', [
616
- 'Provide a valid path without ".." or other potentially unsafe characters',
617
- ]);
188
+ function buildFilesystemTree(currentPath, relativePath, maxDepth, currentDepth, extensions) {
189
+ const name = basename(currentPath);
190
+ const node = { name, type: 'dir', path: relativePath || '.' };
191
+ if (maxDepth !== -1 && currentDepth >= maxDepth) {
192
+ node.children = [];
193
+ return node;
618
194
  }
195
+ const children = [];
619
196
  try {
620
- const projectFile = join(args.projectPath, 'project.godot');
621
- if (!existsSync(projectFile)) {
622
- return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
623
- 'Ensure the path points to a directory containing a project.godot file',
624
- 'Use list_projects to find valid Godot projects',
625
- ]);
626
- }
627
- const background = args.background === true;
628
- runner.runProject(args.projectPath, args.scene, background);
629
- const bridgeResult = await runner.waitForBridge();
630
- if (!bridgeResult.ready) {
631
- if (runner.activeProcess && runner.activeProcess.hasExited) {
632
- return createErrorResponse(`Godot process exited before the MCP bridge could initialize.\n${bridgeResult.error || ''}`, [
633
- 'Check get_debug_output for runtime errors',
634
- 'Verify a display server is available (Wayland/X11)',
635
- 'Check for broken autoloads with list_autoloads',
636
- 'Call stop_project to clean up, then try again',
637
- ]);
197
+ const entries = readdirSync(currentPath, { withFileTypes: true });
198
+ for (const entry of entries) {
199
+ if (entry.name.startsWith('.'))
200
+ continue;
201
+ const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
202
+ if (entry.isDirectory()) {
203
+ children.push(buildFilesystemTree(join(currentPath, entry.name), childRelPath, maxDepth, currentDepth + 1, extensions));
638
204
  }
639
- const lines = [
640
- 'Godot process started, but the MCP bridge did not respond within 8 seconds.',
641
- '- The process is running bridge may still be initializing',
642
- '- Use get_debug_output to investigate',
643
- '- Runtime tools may work if you retry after a moment',
644
- '- Call stop_project when done',
645
- ];
646
- if (background) {
647
- lines.push('- Background mode: window hidden, physical input blocked');
205
+ else if (entry.isFile()) {
206
+ const dotIdx = entry.name.lastIndexOf('.');
207
+ const ext = dotIdx >= 0 ? entry.name.slice(dotIdx + 1).toLowerCase() : '';
208
+ if (extensions && !extensions.includes(ext))
209
+ continue;
210
+ children.push({ name: entry.name, type: 'file', path: childRelPath, extension: ext });
648
211
  }
649
- return createErrorResponse(lines.join('\n'), [
650
- 'Use get_debug_output to inspect the last captured logs',
651
- 'Check that UDP port 9900 is not occupied by another Godot process',
652
- 'Call stop_project to clean up, then run_project again',
653
- ]);
654
212
  }
655
- const lines = [
656
- 'Godot project started and MCP bridge is ready.',
657
- '- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
658
- '- Use get_debug_output to check runtime output and errors',
659
- '- Call stop_project when done',
660
- ];
661
- if (background) {
662
- lines.push('- Background mode: window hidden, physical input blocked');
663
- }
664
- return {
665
- content: [{ type: 'text', text: lines.join('\n') }],
666
- };
667
213
  }
668
- catch (error) {
669
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
670
- return createErrorResponse(`Failed to run Godot project: ${errorMessage}`, [
671
- 'Ensure Godot is installed correctly',
672
- 'Check if the GODOT_PATH environment variable is set correctly',
673
- ]);
214
+ catch (err) {
215
+ logDebug(`buildFilesystemTree error at ${currentPath}: ${err}`);
674
216
  }
217
+ node.children = children;
218
+ return node;
675
219
  }
676
- export async function handleAttachProject(runner, args) {
677
- args = normalizeParameters(args);
678
- if (!args.projectPath) {
679
- return createErrorResponse('Project path is required', [
680
- 'Provide a valid path to a Godot project directory',
681
- ]);
682
- }
683
- if (!validatePath(args.projectPath)) {
684
- return createErrorResponse('Invalid project path', [
685
- 'Provide a valid path without ".." or other potentially unsafe characters',
686
- ]);
687
- }
688
- try {
689
- const projectFile = join(args.projectPath, 'project.godot');
690
- if (!existsSync(projectFile)) {
691
- return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
692
- 'Ensure the path points to a directory containing a project.godot file',
693
- 'Use list_projects to find valid Godot projects',
694
- ]);
220
+ function searchInFiles(rootPath, pattern, fileTypes, caseSensitive, maxResults) {
221
+ const matches = [];
222
+ let truncated = false;
223
+ const searchDir = (currentPath, relBase) => {
224
+ if (truncated)
225
+ return;
226
+ let entries;
227
+ try {
228
+ entries = readdirSync(currentPath, { withFileTypes: true });
695
229
  }
696
- runner.attachProject(args.projectPath);
697
- if (args.waitForBridge === true) {
698
- const bridgeResult = await runner.waitForBridgeAttached();
699
- if (!bridgeResult.ready) {
700
- return createErrorResponse(`Project attached but the MCP bridge is not ready.\n${bridgeResult.error || ''}`, [
701
- 'Verify Godot is running with this project',
702
- 'The McpBridge autoload must be initialized and listening on UDP port 9900',
703
- 'Check that no other Godot project is occupying port 9900',
704
- 'Use detach_project or stop_project when done',
705
- ]);
706
- }
707
- return {
708
- content: [
709
- {
710
- type: 'text',
711
- text: [
712
- 'Project attached and MCP bridge is ready.',
713
- '- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
714
- '- get_debug_output is unavailable in attached mode because MCP did not spawn the process',
715
- '- Use detach_project or stop_project when done to clean up the injected bridge state',
716
- ].join('\n'),
717
- },
718
- ],
719
- };
720
- }
721
- return {
722
- content: [
723
- {
724
- type: 'text',
725
- text: [
726
- 'Project attached for manual runtime use.',
727
- '- Launch Godot yourself, then call attach_project again with waitForBridge: true to confirm readiness',
728
- '- Or use runtime tools directly — they will fail with a clear error if the bridge is not yet listening',
729
- '- get_debug_output is unavailable in attached mode because MCP did not spawn the process',
730
- '- Use detach_project or stop_project when done to clean up the injected bridge state',
731
- ].join('\n'),
732
- },
733
- ],
734
- };
735
- }
736
- catch (error) {
737
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
738
- return createErrorResponse(`Failed to attach project: ${errorMessage}`, [
739
- 'Check if project.godot is accessible',
740
- 'Ensure MCP can write the bridge autoload into the project',
741
- ]);
742
- }
743
- }
744
- export function handleDetachProject(runner) {
745
- if (runner.activeSessionMode !== 'attached') {
746
- return createErrorResponse('No attached project to detach.', [
747
- 'Use attach_project first for manual-launch workflows',
748
- 'If MCP launched the game, use stop_project instead',
749
- ]);
750
- }
751
- const result = runner.stopProject();
752
- return {
753
- content: [
754
- {
755
- type: 'text',
756
- text: JSON.stringify({
757
- message: 'Detached attached project and cleaned MCP bridge state',
758
- externalProcessPreserved: result.externalProcessPreserved === true,
759
- }),
760
- },
761
- ],
762
- };
763
- }
764
- export function handleGetDebugOutput(runner, args = {}) {
765
- args = normalizeParameters(args);
766
- if (!runner.activeSessionMode) {
767
- return createErrorResponse('No active runtime session.', [
768
- 'Use run_project to start a Godot project first',
769
- 'Or use attach_project before launching Godot manually',
770
- ]);
771
- }
772
- if (runner.activeSessionMode === 'attached') {
773
- return {
774
- content: [
775
- {
776
- type: 'text',
777
- text: JSON.stringify({
778
- output: [],
779
- errors: [],
780
- running: null,
781
- attached: true,
782
- tip: 'Attached mode does not capture stdout/stderr because Godot was launched outside MCP.',
783
- }),
784
- },
785
- ],
786
- };
787
- }
788
- const proc = runner.activeProcess;
789
- if (!proc) {
790
- return createErrorResponse('No active spawned process is available for debug output.', [
791
- 'Use run_project to start a Godot project first',
792
- 'Or use attach_project only when stdout/stderr capture is not needed',
793
- ]);
794
- }
795
- const limit = typeof args.limit === 'number' ? args.limit : 200;
796
- const response = {
797
- output: proc.output.slice(-limit),
798
- errors: proc.errors.slice(-limit),
799
- running: !proc.hasExited,
800
- };
801
- if (proc.hasExited) {
802
- response.exitCode = proc.exitCode;
803
- response.tip =
804
- 'Process has exited. Call stop_project to clean up the process slot before starting a new one.';
805
- }
806
- return {
807
- content: [
808
- {
809
- type: 'text',
810
- text: JSON.stringify(response),
811
- },
812
- ],
813
- };
814
- }
815
- export function handleStopProject(runner) {
816
- const result = runner.stopProject();
817
- if (!result) {
818
- return createErrorResponse('No active Godot process to stop.', [
819
- 'Use run_project to start a Godot project first',
820
- 'The process may have already terminated',
821
- ]);
822
- }
823
- return {
824
- content: [
825
- {
826
- type: 'text',
827
- text: JSON.stringify({
828
- message: result.mode === 'attached'
829
- ? 'Attached project detached and MCP bridge state cleaned up'
830
- : 'Godot project stopped',
831
- mode: result.mode,
832
- externalProcessPreserved: result.externalProcessPreserved === true,
833
- finalOutput: result.output.slice(-200),
834
- finalErrors: result.errors.slice(-200),
835
- }),
836
- },
837
- ],
838
- };
839
- }
840
- export async function handleListProjects(args) {
841
- args = normalizeParameters(args);
842
- if (!args.directory) {
843
- return createErrorResponse('Directory is required', [
844
- 'Provide a valid directory path to search for Godot projects',
845
- ]);
846
- }
847
- if (!validatePath(args.directory)) {
848
- return createErrorResponse('Invalid directory path', [
849
- 'Provide a valid path without ".." or other potentially unsafe characters',
850
- ]);
851
- }
852
- try {
853
- if (!existsSync(args.directory)) {
854
- return createErrorResponse(`Directory does not exist: ${args.directory}`, [
855
- 'Provide a valid directory path that exists on the system',
856
- ]);
857
- }
858
- const recursive = args.recursive === true;
859
- const projects = findGodotProjects(args.directory, recursive);
860
- return {
861
- content: [{ type: 'text', text: JSON.stringify(projects) }],
862
- };
863
- }
864
- catch (error) {
865
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
866
- return createErrorResponse(`Failed to list projects: ${errorMessage}`, [
867
- 'Ensure the directory exists and is accessible',
868
- 'Check if you have permission to read the directory',
869
- ]);
870
- }
871
- }
872
- export async function handleGetProjectInfo(runner, args) {
873
- args = normalizeParameters(args);
874
- try {
875
- const version = await runner.getVersion();
876
- // If no project path, return just the Godot version
877
- if (!args.projectPath) {
878
- return {
879
- content: [{ type: 'text', text: JSON.stringify({ godotVersion: version }) }],
880
- };
881
- }
882
- if (!validatePath(args.projectPath)) {
883
- return createErrorResponse('Invalid project path', [
884
- 'Provide a valid path without ".." or other potentially unsafe characters',
885
- ]);
886
- }
887
- const projectFile = join(args.projectPath, 'project.godot');
888
- if (!existsSync(projectFile)) {
889
- return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`, [
890
- 'Ensure the path points to a directory containing a project.godot file',
891
- 'Use list_projects to find valid Godot projects',
892
- ]);
893
- }
894
- const projectStructure = getProjectStructure(args.projectPath);
895
- let projectName = basename(args.projectPath);
896
- try {
897
- const projectFileContent = readFileSync(projectFile, 'utf8');
898
- const configNameMatch = projectFileContent.match(/config\/name="([^"]+)"/);
899
- if (configNameMatch && configNameMatch[1]) {
900
- projectName = configNameMatch[1];
901
- logDebug(`Found project name in config: ${projectName}`);
902
- }
903
- }
904
- catch (error) {
905
- logDebug(`Error reading project file: ${error}`);
906
- }
907
- return {
908
- content: [
909
- {
910
- type: 'text',
911
- text: JSON.stringify({
912
- name: projectName,
913
- path: args.projectPath,
914
- godotVersion: version,
915
- structure: projectStructure,
916
- }),
917
- },
918
- ],
919
- };
920
- }
921
- catch (error) {
922
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
923
- return createErrorResponse(`Failed to get project info: ${errorMessage}`, [
924
- 'Ensure Godot is installed correctly',
925
- 'Check if the GODOT_PATH environment variable is set correctly',
926
- ]);
927
- }
928
- }
929
- export async function handleTakeScreenshot(runner, args) {
930
- args = normalizeParameters(args);
931
- const sessionError = ensureRuntimeSession(runner, 'take a screenshot');
932
- if (sessionError) {
933
- return sessionError;
934
- }
935
- const timeout = typeof args.timeout === 'number' ? args.timeout : 10000;
936
- try {
937
- const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('screenshot', {}, timeout);
938
- let parsed;
939
- try {
940
- parsed = JSON.parse(responseStr);
941
- }
942
- catch {
943
- return createErrorResponse(`Invalid response from screenshot server: ${responseStr}`, [
944
- 'The game may not have fully initialized yet',
945
- 'Try again after a few seconds',
946
- ]);
947
- }
948
- if (parsed.error) {
949
- return createErrorResponse(`Screenshot server error: ${parsed.error}`, [
950
- 'Ensure the game viewport is active',
951
- 'Try again after a moment',
952
- ]);
953
- }
954
- if (!parsed.path) {
955
- return createErrorResponse('Screenshot server returned no file path', [
956
- 'Try again after a few seconds',
957
- ]);
958
- }
959
- // Normalize path for the local filesystem (forward slashes from GDScript)
960
- const screenshotPath = sep === '\\' ? parsed.path.replace(/\//g, '\\') : parsed.path;
961
- if (!existsSync(screenshotPath)) {
962
- return createErrorResponse(`Screenshot file not found at: ${screenshotPath}`, [
963
- 'The screenshot may have failed to save',
964
- 'Check disk space and permissions',
965
- ]);
966
- }
967
- const imageBuffer = readFileSync(screenshotPath);
968
- const base64Data = imageBuffer.toString('base64');
969
- const content = [
970
- {
971
- type: 'image',
972
- data: base64Data,
973
- mimeType: 'image/png',
974
- },
975
- {
976
- type: 'text',
977
- text: `Screenshot saved to: ${parsed.path}`,
978
- },
979
- ];
980
- if (runtimeErrors.length > 0) {
981
- content.push({
982
- type: 'text',
983
- text: JSON.stringify({
984
- warnings: runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES),
985
- }),
986
- });
987
- }
988
- return { content };
989
- }
990
- catch (error) {
991
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
992
- return createErrorResponse(`Failed to take screenshot: ${errorMessage}`, [
993
- 'Ensure the project is running (use run_project first)',
994
- 'The bridge may not be ready yet — use get_debug_output to investigate',
995
- 'Check that UDP port 9900 is not blocked',
996
- ]);
997
- }
998
- }
999
- export async function handleSimulateInput(runner, args) {
1000
- args = normalizeParameters(args);
1001
- const sessionError = ensureRuntimeSession(runner, 'simulate input');
1002
- if (sessionError) {
1003
- return sessionError;
1004
- }
1005
- const actions = args.actions;
1006
- if (!Array.isArray(actions) || actions.length === 0) {
1007
- return createErrorResponse('actions must be a non-empty array of input actions', [
1008
- 'Provide at least one action object with a "type" field',
1009
- ]);
1010
- }
1011
- // Calculate timeout: sum of all wait durations + 10s buffer
1012
- let totalWaitMs = 0;
1013
- for (const action of actions) {
1014
- if (typeof action === 'object' &&
1015
- action !== null &&
1016
- action.type === 'wait' &&
1017
- typeof action.ms === 'number') {
1018
- totalWaitMs += action.ms;
1019
- }
1020
- }
1021
- const timeoutMs = totalWaitMs + 10000;
1022
- try {
1023
- const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('input', { actions }, timeoutMs);
1024
- let parsed;
1025
- try {
1026
- parsed = JSON.parse(responseStr);
1027
- }
1028
- catch {
1029
- return createErrorResponse(`Invalid response from bridge: ${responseStr}`, [
1030
- 'The game may not have fully initialized yet',
1031
- 'Try again after a few seconds',
1032
- ]);
1033
- }
1034
- if (parsed.error) {
1035
- return createErrorResponse(`Input simulation error: ${parsed.error}`, [
1036
- 'Check action types and parameters',
1037
- 'Ensure key names are valid Godot key names',
1038
- ]);
1039
- }
1040
- const payload = {
1041
- success: true,
1042
- actions_processed: parsed.actions_processed,
1043
- tip: 'Call take_screenshot to verify the input had the intended visual effect.',
1044
- };
1045
- if (runtimeErrors.length > 0) {
1046
- payload.warnings = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES);
1047
- }
1048
- return {
1049
- content: [
1050
- {
1051
- type: 'text',
1052
- text: JSON.stringify(payload),
1053
- },
1054
- ],
1055
- };
1056
- }
1057
- catch (error) {
1058
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1059
- return createErrorResponse(`Failed to simulate input: ${errorMessage}`, [
1060
- 'Ensure the project is running (use run_project first)',
1061
- 'The bridge may not be ready yet — use get_debug_output to investigate',
1062
- 'Check that UDP port 9900 is not blocked',
1063
- ]);
1064
- }
1065
- }
1066
- export async function handleGetUiElements(runner, args) {
1067
- args = normalizeParameters(args);
1068
- const sessionError = ensureRuntimeSession(runner, 'query UI elements');
1069
- if (sessionError) {
1070
- return sessionError;
1071
- }
1072
- const visibleOnly = args.visibleOnly !== false;
1073
- try {
1074
- const cmdParams = { visible_only: visibleOnly };
1075
- if (args.filter)
1076
- cmdParams.type_filter = args.filter;
1077
- const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('get_ui_elements', cmdParams);
1078
- let parsed;
1079
- try {
1080
- parsed = JSON.parse(responseStr);
1081
- }
1082
- catch {
1083
- return createErrorResponse(`Invalid response from bridge: ${responseStr}`, [
1084
- 'The game may not have fully initialized yet',
1085
- 'Try again after a few seconds',
1086
- ]);
1087
- }
1088
- if (parsed.error) {
1089
- return createErrorResponse(`UI element query error: ${parsed.error}`, [
1090
- 'Ensure the game has a UI with Control nodes',
1091
- ]);
1092
- }
1093
- const payload = {
1094
- ...parsed,
1095
- tip: "Use simulate_input with type 'click_element' and a node_path or text value from this list to interact with these elements.",
1096
- };
1097
- if (runtimeErrors.length > 0) {
1098
- payload.warnings = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES);
1099
- }
1100
- return {
1101
- content: [
1102
- {
1103
- type: 'text',
1104
- text: JSON.stringify(payload),
1105
- },
1106
- ],
1107
- };
1108
- }
1109
- catch (error) {
1110
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1111
- return createErrorResponse(`Failed to get UI elements: ${errorMessage}`, [
1112
- 'Ensure the project is running (use run_project first)',
1113
- 'The bridge may not be ready yet — use get_debug_output to investigate',
1114
- 'Check that UDP port 9900 is not blocked',
1115
- ]);
1116
- }
1117
- }
1118
- export async function handleRunScript(runner, args) {
1119
- args = normalizeParameters(args);
1120
- const sessionError = ensureRuntimeSession(runner, 'execute scripts');
1121
- if (sessionError) {
1122
- return sessionError;
1123
- }
1124
- const script = args.script;
1125
- if (typeof script !== 'string' || script.trim() === '') {
1126
- return createErrorResponse('script is required and must be a non-empty string', [
1127
- 'Provide GDScript source code with extends RefCounted and func execute(scene_tree: SceneTree) -> Variant',
1128
- ]);
1129
- }
1130
- if (!script.includes('func execute')) {
1131
- return createErrorResponse('Script must define func execute(scene_tree: SceneTree) -> Variant', ['Add a func execute(scene_tree: SceneTree) -> Variant method to your script']);
1132
- }
1133
- // Write script to .mcp/scripts/ for audit trail
1134
- try {
1135
- const projectPath = runner.activeProjectPath;
1136
- if (projectPath) {
1137
- const scriptsDir = join(projectPath, '.mcp', 'scripts');
1138
- mkdirSync(scriptsDir, { recursive: true });
1139
- const timestamp = Date.now();
1140
- const scriptFile = join(scriptsDir, `${timestamp}.gd`);
1141
- writeFileSync(scriptFile, script, 'utf8');
1142
- logDebug(`Saved script to ${scriptFile}`);
1143
- }
1144
- }
1145
- catch (error) {
1146
- logDebug(`Failed to save script for audit: ${error}`);
1147
- }
1148
- const timeout = typeof args.timeout === 'number' ? args.timeout : 30000;
1149
- try {
1150
- const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('run_script', { source: script }, timeout);
1151
- let parsed;
1152
- try {
1153
- parsed = JSON.parse(responseStr);
1154
- }
1155
- catch {
1156
- return createErrorResponse(`Invalid response from bridge: ${responseStr}`, [
1157
- 'The script may have produced non-JSON output',
1158
- 'Check get_debug_output for print() statements',
1159
- ]);
1160
- }
1161
- if (parsed.error) {
1162
- return createErrorResponse(`Script execution error: ${parsed.error}`, [
1163
- 'Check your GDScript syntax',
1164
- 'Ensure the script extends RefCounted',
1165
- 'Check get_debug_output for details',
1166
- ]);
1167
- }
1168
- // Detect false-positive success: GDScript has no try-catch, so runtime errors
1169
- // return null and the real error only appears in stderr.
1170
- if (parsed.success && parsed.result === null && runner.activeSessionMode === 'spawned') {
1171
- if (runtimeErrors.length > 0) {
1172
- const errorContext = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES).join('\n');
1173
- return createErrorResponse(`Script runtime error detected:\n${errorContext}`, [
1174
- 'Fix the GDScript error in your script and retry',
1175
- 'Use get_debug_output for full process output',
1176
- ]);
1177
- }
1178
- return {
1179
- content: [
1180
- {
1181
- type: 'text',
1182
- text: JSON.stringify({
1183
- success: true,
1184
- result: null,
1185
- warning: 'Script returned null. If unexpected, check get_debug_output for runtime errors — GDScript does not propagate exceptions.',
1186
- tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
1187
- }),
1188
- },
1189
- ],
1190
- };
1191
- }
1192
- const payload = {
1193
- success: true,
1194
- result: parsed.result,
1195
- tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
1196
- };
1197
- if (runtimeErrors.length > 0) {
1198
- payload.warnings = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES);
1199
- }
1200
- return {
1201
- content: [
1202
- {
1203
- type: 'text',
1204
- text: JSON.stringify(payload),
1205
- },
1206
- ],
1207
- };
1208
- }
1209
- catch (error) {
1210
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1211
- return createErrorResponse(`Failed to execute script: ${errorMessage}`, [
1212
- 'Ensure the project is running (use run_project first)',
1213
- 'The bridge may not be ready yet — wait 2-3 seconds after starting, then check get_debug_output if the issue persists',
1214
- 'Check that UDP port 9900 is not blocked',
1215
- 'For long-running scripts, increase the timeout parameter',
1216
- ]);
1217
- }
1218
- }
1219
- function buildFilesystemTree(currentPath, relativePath, maxDepth, currentDepth, extensions) {
1220
- const name = basename(currentPath);
1221
- const node = { name, type: 'dir', path: relativePath || '.' };
1222
- if (maxDepth !== -1 && currentDepth >= maxDepth) {
1223
- node.children = [];
1224
- return node;
1225
- }
1226
- const children = [];
1227
- try {
1228
- const entries = readdirSync(currentPath, { withFileTypes: true });
1229
- for (const entry of entries) {
1230
- if (entry.name.startsWith('.'))
1231
- continue;
1232
- const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
1233
- if (entry.isDirectory()) {
1234
- children.push(buildFilesystemTree(join(currentPath, entry.name), childRelPath, maxDepth, currentDepth + 1, extensions));
1235
- }
1236
- else if (entry.isFile()) {
1237
- const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : '';
1238
- if (extensions && !extensions.includes(ext))
1239
- continue;
1240
- children.push({ name: entry.name, type: 'file', path: childRelPath, extension: ext });
1241
- }
1242
- }
1243
- }
1244
- catch (err) {
1245
- logDebug(`buildFilesystemTree error at ${currentPath}: ${err}`);
1246
- }
1247
- node.children = children;
1248
- return node;
1249
- }
1250
- function searchInFiles(rootPath, pattern, fileTypes, caseSensitive, maxResults) {
1251
- const matches = [];
1252
- let truncated = false;
1253
- const searchDir = (currentPath, relBase) => {
1254
- if (truncated)
1255
- return;
1256
- let entries;
1257
- try {
1258
- entries = readdirSync(currentPath, { withFileTypes: true });
1259
- }
1260
- catch (err) {
1261
- logDebug(`searchInFiles readdir error at ${currentPath}: ${err}`);
1262
- return;
230
+ catch (err) {
231
+ logDebug(`searchInFiles readdir error at ${currentPath}: ${err}`);
232
+ return;
1263
233
  }
1264
234
  for (const entry of entries) {
1265
235
  if (truncated)
@@ -1272,7 +242,8 @@ function searchInFiles(rootPath, pattern, fileTypes, caseSensitive, maxResults)
1272
242
  searchDir(fullPath, childRelPath);
1273
243
  }
1274
244
  else if (entry.isFile()) {
1275
- const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : '';
245
+ const dotIdx = entry.name.lastIndexOf('.');
246
+ const ext = dotIdx >= 0 ? entry.name.slice(dotIdx + 1).toLowerCase() : '';
1276
247
  if (!fileTypes.includes(ext))
1277
248
  continue;
1278
249
  let content;
@@ -1339,122 +310,83 @@ function parseProjectSettings(projectFilePath) {
1339
310
  }
1340
311
  return result;
1341
312
  }
1342
- export async function handleListAutoloads(args) {
313
+ // --- Handlers ---
314
+ export async function handleListProjects(args) {
1343
315
  args = normalizeParameters(args);
1344
- const v = validateProjectArgs(args);
1345
- if ('isError' in v)
1346
- return v;
1347
- try {
1348
- const projectFile = join(v.projectPath, 'project.godot');
1349
- const autoloads = parseAutoloads(projectFile);
1350
- return { content: [{ type: 'text', text: JSON.stringify({ autoloads }) }] };
1351
- }
1352
- catch (error) {
1353
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1354
- return createErrorResponse(`Failed to list autoloads: ${errorMessage}`, [
1355
- 'Check if project.godot is accessible',
316
+ if (!args.directory) {
317
+ return createErrorResponse('Directory is required', [
318
+ 'Provide a valid directory path to search for Godot projects',
1356
319
  ]);
1357
320
  }
1358
- }
1359
- export async function handleAddAutoload(args) {
1360
- args = normalizeParameters(args);
1361
- const v = validateProjectArgs(args);
1362
- if ('isError' in v)
1363
- return v;
1364
- if (!args.autoloadName || !args.autoloadPath) {
1365
- return createErrorResponse('autoloadName and autoloadPath are required', [
1366
- 'Provide the autoload node name and script/scene path',
321
+ if (!validatePath(args.directory)) {
322
+ return createErrorResponse('Invalid directory path', [
323
+ 'Provide a valid path without ".." or other potentially unsafe characters',
1367
324
  ]);
1368
325
  }
1369
- if (!validatePath(args.autoloadPath)) {
1370
- return createErrorResponse('Invalid autoload path', ['Provide a valid path without ".."']);
1371
- }
1372
326
  try {
1373
- const projectFile = join(v.projectPath, 'project.godot');
1374
- const existing = parseAutoloads(projectFile);
1375
- if (existing.some((a) => a.name === args.autoloadName)) {
1376
- return createErrorResponse(`Autoload '${args.autoloadName}' already exists`, [
1377
- 'Use update_autoload to modify it',
1378
- 'Use list_autoloads to see current autoloads',
327
+ if (!existsSync(args.directory)) {
328
+ return createErrorResponse(`Directory does not exist: ${args.directory}`, [
329
+ 'Provide a valid directory path that exists on the system',
1379
330
  ]);
1380
331
  }
1381
- const isSingleton = args.singleton !== false;
1382
- addAutoloadEntry(projectFile, args.autoloadName, args.autoloadPath, isSingleton);
332
+ const recursive = args.recursive === true;
333
+ const projects = findGodotProjects(args.directory, recursive);
1383
334
  return {
1384
- content: [
1385
- {
1386
- type: 'text',
1387
- text: `Autoload '${args.autoloadName}' registered at '${args.autoloadPath}' (singleton: ${isSingleton}).\nWarning: autoloads initialize in headless mode too. If this script has errors, all headless operations will fail. Verify by running get_scene_tree — if it fails, use remove_autoload to remove it.`,
1388
- },
1389
- ],
335
+ content: [{ type: 'text', text: JSON.stringify(projects) }],
1390
336
  };
1391
337
  }
1392
338
  catch (error) {
1393
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1394
- return createErrorResponse(`Failed to add autoload: ${errorMessage}`, [
1395
- 'Check if project.godot is accessible',
339
+ return createErrorResponse(`Failed to list projects: ${getErrorMessage(error)}`, [
340
+ 'Ensure the directory exists and is accessible',
341
+ 'Check if you have permission to read the directory',
1396
342
  ]);
1397
343
  }
1398
344
  }
1399
- export async function handleRemoveAutoload(args) {
345
+ export async function handleGetProjectInfo(runner, args) {
1400
346
  args = normalizeParameters(args);
1401
- const v = validateProjectArgs(args);
1402
- if ('isError' in v)
1403
- return v;
1404
- if (!args.autoloadName) {
1405
- return createErrorResponse('autoloadName is required', [
1406
- 'Provide the name of the autoload to remove',
1407
- ]);
1408
- }
1409
347
  try {
1410
- const projectFile = join(v.projectPath, 'project.godot');
1411
- const removed = removeAutoloadEntry(projectFile, args.autoloadName);
1412
- if (!removed) {
1413
- return createErrorResponse(`Autoload '${args.autoloadName}' not found`, [
1414
- 'Use list_autoloads to see existing autoloads',
1415
- ]);
348
+ const version = await runner.getVersion();
349
+ // If no project path, return just the Godot version
350
+ if (!args.projectPath) {
351
+ return {
352
+ content: [{ type: 'text', text: JSON.stringify({ godotVersion: version }) }],
353
+ };
1416
354
  }
1417
- return {
1418
- content: [{ type: 'text', text: `Autoload '${args.autoloadName}' removed successfully.` }],
1419
- };
1420
- }
1421
- catch (error) {
1422
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1423
- return createErrorResponse(`Failed to remove autoload: ${errorMessage}`, [
1424
- 'Check if project.godot is accessible',
1425
- ]);
1426
- }
1427
- }
1428
- export async function handleUpdateAutoload(args) {
1429
- args = normalizeParameters(args);
1430
- const v = validateProjectArgs(args);
1431
- if ('isError' in v)
1432
- return v;
1433
- if (!args.autoloadName) {
1434
- return createErrorResponse('autoloadName is required', [
1435
- 'Provide the name of the autoload to update',
1436
- ]);
1437
- }
1438
- if (args.autoloadPath && !validatePath(args.autoloadPath)) {
1439
- return createErrorResponse('Invalid autoload path', ['Provide a valid path without ".."']);
1440
- }
1441
- try {
1442
- const projectFile = join(v.projectPath, 'project.godot');
1443
- const updated = updateAutoloadEntry(projectFile, args.autoloadName, args.autoloadPath, args.singleton);
1444
- if (!updated) {
1445
- return createErrorResponse(`Autoload '${args.autoloadName}' not found`, [
1446
- 'Use list_autoloads to see existing autoloads',
1447
- 'Use add_autoload to register a new one',
1448
- ]);
355
+ const v = validateProjectArgs(args);
356
+ if ('isError' in v)
357
+ return v;
358
+ const projectFile = projectGodotPath(v.projectPath);
359
+ const projectStructure = getProjectStructure(v.projectPath);
360
+ let projectName = basename(v.projectPath);
361
+ try {
362
+ const projectFileContent = readFileSync(projectFile, 'utf8');
363
+ const configNameMatch = projectFileContent.match(/config\/name="([^"]+)"/);
364
+ if (configNameMatch && configNameMatch[1]) {
365
+ projectName = configNameMatch[1];
366
+ logDebug(`Found project name in config: ${projectName}`);
367
+ }
368
+ }
369
+ catch (error) {
370
+ logDebug(`Error reading project file: ${error}`);
1449
371
  }
1450
372
  return {
1451
- content: [{ type: 'text', text: `Autoload '${args.autoloadName}' updated successfully.` }],
373
+ content: [
374
+ {
375
+ type: 'text',
376
+ text: JSON.stringify({
377
+ name: projectName,
378
+ path: v.projectPath,
379
+ godotVersion: version,
380
+ structure: projectStructure,
381
+ }),
382
+ },
383
+ ],
1452
384
  };
1453
385
  }
1454
386
  catch (error) {
1455
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1456
- return createErrorResponse(`Failed to update autoload: ${errorMessage}`, [
1457
- 'Check if project.godot is accessible',
387
+ return createErrorResponse(`Failed to get project info: ${getErrorMessage(error)}`, [
388
+ 'Ensure Godot is installed correctly',
389
+ 'Check if the GODOT_PATH environment variable is set correctly',
1458
390
  ]);
1459
391
  }
1460
392
  }
@@ -1472,8 +404,7 @@ export async function handleGetProjectFiles(args) {
1472
404
  return { content: [{ type: 'text', text: JSON.stringify(tree) }] };
1473
405
  }
1474
406
  catch (error) {
1475
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1476
- return createErrorResponse(`Failed to get project files: ${errorMessage}`, [
407
+ return createErrorResponse(`Failed to get project files: ${getErrorMessage(error)}`, [
1477
408
  'Check if the project directory is accessible',
1478
409
  ]);
1479
410
  }
@@ -1496,8 +427,7 @@ export async function handleSearchProject(args) {
1496
427
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
1497
428
  }
1498
429
  catch (error) {
1499
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1500
- return createErrorResponse(`Failed to search project: ${errorMessage}`, [
430
+ return createErrorResponse(`Failed to search project: ${getErrorMessage(error)}`, [
1501
431
  'Check if the project directory is accessible',
1502
432
  ]);
1503
433
  }
@@ -1548,8 +478,7 @@ export async function handleGetSceneDependencies(args) {
1548
478
  };
1549
479
  }
1550
480
  catch (error) {
1551
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1552
- return createErrorResponse(`Failed to get scene dependencies: ${errorMessage}`, [
481
+ return createErrorResponse(`Failed to get scene dependencies: ${getErrorMessage(error)}`, [
1553
482
  'Check if the scene file is accessible',
1554
483
  ]);
1555
484
  }
@@ -1560,7 +489,7 @@ export async function handleGetProjectSettings(args) {
1560
489
  if ('isError' in v)
1561
490
  return v;
1562
491
  try {
1563
- const projectFile = join(v.projectPath, 'project.godot');
492
+ const projectFile = projectGodotPath(v.projectPath);
1564
493
  const allSettings = parseProjectSettings(projectFile);
1565
494
  if (args.section && typeof args.section === 'string') {
1566
495
  const sectionData = allSettings[args.section] ?? {};
@@ -1569,8 +498,7 @@ export async function handleGetProjectSettings(args) {
1569
498
  return { content: [{ type: 'text', text: JSON.stringify({ settings: allSettings }) }] };
1570
499
  }
1571
500
  catch (error) {
1572
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1573
- return createErrorResponse(`Failed to get project settings: ${errorMessage}`, [
501
+ return createErrorResponse(`Failed to get project settings: ${getErrorMessage(error)}`, [
1574
502
  'Check if project.godot is accessible',
1575
503
  ]);
1576
504
  }