@beremaran/godot-agent-loop 1.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 (70) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +925 -0
  3. package/addons/godot_agent_loop/LICENSE +23 -0
  4. package/addons/godot_agent_loop/README.md +31 -0
  5. package/addons/godot_agent_loop/plugin.cfg +8 -0
  6. package/addons/godot_agent_loop/plugin.gd +417 -0
  7. package/agent-plugin/.claude-plugin/plugin.json +19 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +37 -0
  9. package/agent-plugin/.mcp.json +14 -0
  10. package/agent-plugin/adapter-manifest.json +45 -0
  11. package/agent-plugin/pi/extension.ts +136 -0
  12. package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
  13. package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
  14. package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
  15. package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
  16. package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
  17. package/build/authoring-session-manager.js +237 -0
  18. package/build/domain-tool-registries.js +207 -0
  19. package/build/editor-bridge-protocol.js +1 -0
  20. package/build/editor-connection.js +112 -0
  21. package/build/editor-mutation-guard.js +30 -0
  22. package/build/editor-plugin-installer.js +220 -0
  23. package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
  24. package/build/game-command-service.js +49 -0
  25. package/build/game-connection.js +337 -0
  26. package/build/godot-executable.js +156 -0
  27. package/build/godot-process-manager.js +112 -0
  28. package/build/godot-subprocess.js +23 -0
  29. package/build/headless-operation-runner.js +68 -0
  30. package/build/headless-operation-service.js +65 -0
  31. package/build/index.js +478 -0
  32. package/build/interaction-server-installer.js +234 -0
  33. package/build/opencode-setup.js +199 -0
  34. package/build/project-support.js +219 -0
  35. package/build/runtime-protocol.js +202 -0
  36. package/build/scripts/godot_operations.gd +1534 -0
  37. package/build/scripts/mcp_editor_plugin.gd +417 -0
  38. package/build/scripts/mcp_interaction_server.gd +1255 -0
  39. package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
  40. package/build/scripts/mcp_runtime/command_params.gd +339 -0
  41. package/build/scripts/mcp_runtime/core_domain.gd +591 -0
  42. package/build/scripts/mcp_runtime/input_domain.gd +695 -0
  43. package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
  44. package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
  45. package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
  46. package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
  47. package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
  48. package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
  49. package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
  50. package/build/scripts/mcp_runtime/system_domain.gd +277 -0
  51. package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
  52. package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
  53. package/build/scripts/validate_script.gd +28 -0
  54. package/build/server-instructions.js +11 -0
  55. package/build/session-timing.js +20 -0
  56. package/build/tool-argument-validation.js +120 -0
  57. package/build/tool-definitions.js +2727 -0
  58. package/build/tool-handlers/game-tool-handlers.js +1345 -0
  59. package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
  60. package/build/tool-handlers/project-handler-services.js +1458 -0
  61. package/build/tool-handlers/project-tool-handlers.js +1506 -0
  62. package/build/tool-handlers/visual-regression-service.js +139 -0
  63. package/build/tool-manifest.js +1193 -0
  64. package/build/tool-mutation-policy.js +122 -0
  65. package/build/tool-registry.js +34 -0
  66. package/build/tool-surface.js +70 -0
  67. package/build/utils.js +273 -0
  68. package/package.json +110 -0
  69. package/product.json +52 -0
  70. package/scripts/prepare-package.js +42 -0
@@ -0,0 +1,68 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { convertCamelToSnakeCase } from './utils.js';
4
+ import { GODOT_COMMAND_OPTIONS } from './godot-subprocess.js';
5
+ const execFileAsync = promisify(execFile);
6
+ /**
7
+ * Describe operation parameters without disclosing their values: a param can
8
+ * carry a project file path, a script's source, or another secret the debug log
9
+ * has no reason to keep.
10
+ */
11
+ function redactValue(value) {
12
+ if (value === null)
13
+ return 'null';
14
+ if (typeof value === 'string')
15
+ return `<string, ${value.length} chars>`;
16
+ if (Array.isArray(value))
17
+ return `<array, ${value.length} items>`;
18
+ if (typeof value === 'object')
19
+ return `<object, ${Object.keys(value).length} keys>`;
20
+ if (typeof value === 'number' || typeof value === 'boolean')
21
+ return String(value);
22
+ return `<${typeof value}>`;
23
+ }
24
+ function redactParams(params) {
25
+ const entries = Object.entries(params).map(([key, value]) => `${key}: ${redactValue(value)}`);
26
+ return `{${entries.join(', ')}}`;
27
+ }
28
+ export class HeadlessOperationRunner {
29
+ options;
30
+ constructor(options) {
31
+ this.options = options;
32
+ }
33
+ async execute(operation, params, projectPath) {
34
+ const logDebug = this.options.logDebug ?? (() => undefined);
35
+ logDebug(`Executing operation: ${operation} in project: ${projectPath}`);
36
+ logDebug(`Original operation params: ${redactParams(params)}`);
37
+ const snakeCaseParams = convertCamelToSnakeCase(params);
38
+ logDebug(`Converted snake_case params: ${redactParams(snakeCaseParams)}`);
39
+ const godotPath = await this.options.resolveGodotPath();
40
+ const paramsJson = JSON.stringify(snakeCaseParams);
41
+ const args = ['--headless', '--path', projectPath, '--script', this.options.operationsScriptPath, operation, paramsJson];
42
+ if (this.options.debugGodot ?? false)
43
+ args.push('--debug-godot');
44
+ const loggableArgs = args.map(arg => (arg === paramsJson ? '<params>' : arg));
45
+ logDebug(`Executing: ${godotPath} ${loggableArgs.join(' ')}`);
46
+ try {
47
+ const { stdout, stderr } = await execFileAsync(godotPath, args, {
48
+ ...GODOT_COMMAND_OPTIONS,
49
+ env: { ...process.env, GODOT_MCP_RUNTIME_DISABLED: 'true' },
50
+ });
51
+ return { stdout: stdout ?? '', stderr: stderr ?? '', exitCode: 0, signal: null };
52
+ }
53
+ catch (error) {
54
+ if (error instanceof Error && 'stdout' in error && 'stderr' in error && 'code' in error) {
55
+ const execError = error;
56
+ if (typeof execError.code === 'number' || execError.signal) {
57
+ return {
58
+ stdout: execError.stdout ?? '',
59
+ stderr: execError.stderr ?? '',
60
+ exitCode: typeof execError.code === 'number' ? execError.code : null,
61
+ signal: execError.signal ?? null,
62
+ };
63
+ }
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { createErrorResponse, errorMessage, validatePath, PathSecurity } from './utils.js';
4
+ import { AuthoringSessionUnavailableError } from './authoring-session-manager.js';
5
+ import { authoringBackendForOperation } from './tool-manifest.js';
6
+ /** Coordinates validated authoring operations and their declared backend fallback. */
7
+ export class HeadlessOperationService {
8
+ runner;
9
+ pathSecurity;
10
+ authoringSession;
11
+ constructor(runner, pathSecurity = new PathSecurity(), authoringSession) {
12
+ this.runner = runner;
13
+ this.pathSecurity = pathSecurity;
14
+ this.authoringSession = authoringSession;
15
+ }
16
+ async execute(operation, params, projectPath) {
17
+ const backend = authoringBackendForOperation(operation);
18
+ if (backend && this.authoringSession) {
19
+ try {
20
+ return await this.authoringSession.execute(backend, params, projectPath);
21
+ }
22
+ catch (error) {
23
+ if (!(error instanceof AuthoringSessionUnavailableError))
24
+ throw error;
25
+ }
26
+ }
27
+ return this.runner.execute(backend?.fallback.operation ?? operation, params, projectPath);
28
+ }
29
+ async run(operation, projectPath, params) {
30
+ if (!projectPath)
31
+ return createErrorResponse('projectPath is required.');
32
+ if (!validatePath(projectPath))
33
+ return createErrorResponse('Invalid path.');
34
+ if (!this.pathSecurity.isProjectPathAllowed(projectPath))
35
+ return createErrorResponse('Project path is outside the allowed roots.');
36
+ if (!existsSync(join(projectPath, 'project.godot')))
37
+ return createErrorResponse(`Not a valid Godot project: ${projectPath}`);
38
+ if (!this.pathsAreSafe(projectPath, params))
39
+ return createErrorResponse('A project-relative path escapes the project root.');
40
+ try {
41
+ const { stdout, stderr, exitCode, signal } = await this.execute(operation, params, projectPath);
42
+ if (exitCode !== 0 || signal !== null) {
43
+ const details = signal ? `terminated by signal ${signal}` : `exited with code ${exitCode}`;
44
+ const output = stderr || stdout;
45
+ return createErrorResponse(`${operation} failed (${details})${output ? `: ${output}` : '.'}`);
46
+ }
47
+ return { content: [{ type: 'text', text: `${operation} succeeded.\n\nOutput: ${stdout}` }] };
48
+ }
49
+ catch (error) {
50
+ return createErrorResponse(`${operation} failed: ${errorMessage(error)}`);
51
+ }
52
+ }
53
+ pathsAreSafe(projectPath, params) {
54
+ for (const [key, value] of Object.entries(params)) {
55
+ if (!/(?:scenePath|filePath|scriptPath|resourcePath|shaderPath|themePath|translationPath|outputPath|directoryPath|newPath|texturePath|presetPath|paths)$/i.test(key))
56
+ continue;
57
+ const values = Array.isArray(value) ? value : [value];
58
+ for (const candidate of values) {
59
+ if (typeof candidate === 'string' && !this.pathSecurity.isRelativePathAllowed(projectPath, candidate))
60
+ return false;
61
+ }
62
+ }
63
+ return true;
64
+ }
65
+ }
package/build/index.js ADDED
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Godot Agent Loop server
4
+ *
5
+ * This MCP server provides tools for interacting with the Godot game engine.
6
+ * It enables AI assistants to launch the Godot editor, run Godot projects,
7
+ * capture debug output, and control project execution.
8
+ */
9
+ import { fileURLToPath } from 'url';
10
+ import { createRequire } from 'module';
11
+ import { join, dirname, normalize } from 'path';
12
+ import { randomBytes } from 'crypto';
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
16
+ import { PathSecurity } from './utils.js';
17
+ import { GodotExecutableService, GodotExecutableValidator } from './godot-executable.js';
18
+ import { HeadlessOperationRunner } from './headless-operation-runner.js';
19
+ import { HeadlessOperationService } from './headless-operation-service.js';
20
+ import { GameCommandService } from './game-command-service.js';
21
+ import { InteractionServerInstaller } from './interaction-server-installer.js';
22
+ import { GameConnection } from './game-connection.js';
23
+ import { ToolRegistry } from './tool-registry.js';
24
+ import { createToolHandlers } from './domain-tool-registries.js';
25
+ import { GodotProcessManager } from './godot-process-manager.js';
26
+ import { GameToolHandlers } from './tool-handlers/game-tool-handlers.js';
27
+ import { ProjectToolHandlers } from './tool-handlers/project-tool-handlers.js';
28
+ import { LifecycleToolHandlers } from './tool-handlers/lifecycle-tool-handlers.js';
29
+ import { ProjectSupport } from './project-support.js';
30
+ import { EditorConnection } from './editor-connection.js';
31
+ import { EditorPluginInstaller } from './editor-plugin-installer.js';
32
+ import { PRIVILEGED_RUNTIME_GROUPS } from './runtime-protocol.js';
33
+ import { AuthoringSessionManager } from './authoring-session-manager.js';
34
+ import { EditorMutationGuard } from './editor-mutation-guard.js';
35
+ import { SERVER_INSTRUCTIONS } from './server-instructions.js';
36
+ import { advertisedToolDefinitions } from './tool-surface.js';
37
+ import { runOpenCodeSetup } from './opencode-setup.js';
38
+ // Check if debug mode is enabled
39
+ const DEBUG_MODE = process.env.DEBUG === 'true';
40
+ const ALLOW_PRIVILEGED_COMMANDS = process.env.GODOT_MCP_ALLOW_PRIVILEGED_COMMANDS === 'true';
41
+ const RUNTIME_SECRET = process.env.GODOT_MCP_RUNTIME_SECRET || randomBytes(32).toString('base64url');
42
+ function resolvePrivilegedGroups() {
43
+ const configured = process.env.GODOT_MCP_PRIVILEGED_GROUPS ?? '';
44
+ const requested = configured.split(',').map(value => value.trim()).filter(Boolean);
45
+ const invalid = requested.filter(value => !PRIVILEGED_RUNTIME_GROUPS.includes(value));
46
+ if (invalid.length > 0) {
47
+ console.error(`[SERVER] Ignoring unknown GODOT_MCP_PRIVILEGED_GROUPS values: ${invalid.join(', ')}`);
48
+ }
49
+ return [...new Set(requested.filter((value) => PRIVILEGED_RUNTIME_GROUPS.includes(value)))];
50
+ }
51
+ const ALLOWED_PRIVILEGED_GROUPS = resolvePrivilegedGroups();
52
+ /**
53
+ * The loopback port shared with the in-game interaction server. The spawned
54
+ * game process inherits this environment variable, so both ends agree; the
55
+ * override exists so parallel server instances (and the E2E harness) can each
56
+ * use an isolated port.
57
+ */
58
+ function resolveRuntimePort() {
59
+ const configured = process.env.GODOT_MCP_RUNTIME_PORT;
60
+ if (!configured)
61
+ return 9090;
62
+ const parsed = Number(configured);
63
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed >= 65536) {
64
+ console.error(`[SERVER] Ignoring invalid GODOT_MCP_RUNTIME_PORT=${configured}; using 9090`);
65
+ return 9090;
66
+ }
67
+ return parsed;
68
+ }
69
+ function resolveEditorPort() {
70
+ const configured = process.env.GODOT_MCP_EDITOR_PORT;
71
+ if (!configured)
72
+ return 9091;
73
+ const parsed = Number(configured);
74
+ return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : 9091;
75
+ }
76
+ const pathSecurity = new PathSecurity();
77
+ // Derive __filename and __dirname in ESM
78
+ const __filename = fileURLToPath(import.meta.url);
79
+ const __dirname = dirname(__filename);
80
+ const require = createRequire(import.meta.url);
81
+ const packageMetadata = require('../package.json');
82
+ const SERVER_VERSION = packageMetadata.version;
83
+ /**
84
+ * Main server class for Godot Agent Loop
85
+ */
86
+ export class GodotServer {
87
+ server;
88
+ processManager = new GodotProcessManager(message => {
89
+ this.logDebug(message);
90
+ });
91
+ get activeProcess() {
92
+ return this.processManager.activeProcess;
93
+ }
94
+ set activeProcess(process) {
95
+ // Preserve the existing test seam while process ownership moves to the manager.
96
+ this.processManager.seedActiveProcess(process);
97
+ }
98
+ executable;
99
+ get godotPath() {
100
+ return this.executable.path;
101
+ }
102
+ set godotPath(path) {
103
+ this.executable.path = path;
104
+ }
105
+ gameToolHandlers;
106
+ projectToolHandlers;
107
+ lifecycleToolHandlers;
108
+ projectSupport;
109
+ operationsScriptPath;
110
+ interactionServerInstaller;
111
+ executableValidator;
112
+ operationRunner;
113
+ authoringSession;
114
+ headlessOperations;
115
+ gameCommands;
116
+ editorConnection = new EditorConnection({
117
+ port: resolveEditorPort(), secret: RUNTIME_SECRET, serverVersion: SERVER_VERSION,
118
+ });
119
+ editorMutationGuard = new EditorMutationGuard((command, params, timeoutMs) => this.editorConnection.send(command, params, timeoutMs));
120
+ toolRegistry = null;
121
+ editorPluginInstaller;
122
+ editorProjectPath = null;
123
+ editorPluginInstallation = null;
124
+ strictPathValidation = false;
125
+ tcpGameConnection = new GameConnection({
126
+ port: resolveRuntimePort(),
127
+ allowPrivilegedCommands: ALLOW_PRIVILEGED_COMMANDS,
128
+ allowedPrivilegedGroups: ALLOWED_PRIVILEGED_GROUPS,
129
+ authSecret: RUNTIME_SECRET,
130
+ log: message => { this.logDebug(message); },
131
+ onLifecycleEvent: event => { this.forwardEditorActivity(event); },
132
+ });
133
+ get gameConnection() {
134
+ return this.tcpGameConnection;
135
+ }
136
+ constructor(config) {
137
+ // Apply configuration if provided
138
+ let debugMode = DEBUG_MODE;
139
+ this.executableValidator = new GodotExecutableValidator(message => { this.logDebug(message); });
140
+ this.executable = new GodotExecutableService(this.executableValidator, config?.strictPathValidation ?? false, message => { this.logDebug(message); });
141
+ if (config) {
142
+ if (config.debugMode !== undefined) {
143
+ debugMode = config.debugMode;
144
+ }
145
+ if (config.strictPathValidation !== undefined) {
146
+ this.strictPathValidation = config.strictPathValidation;
147
+ }
148
+ // Store and validate custom Godot path if provided
149
+ if (config.godotPath) {
150
+ const normalizedPath = normalize(config.godotPath);
151
+ this.godotPath = normalizedPath;
152
+ this.logDebug(`Custom Godot path provided: ${this.godotPath}`);
153
+ // Validate immediately with sync check
154
+ if (!this.isValidGodotPathSync(this.godotPath)) {
155
+ console.warn(`[SERVER] Invalid custom Godot path provided: ${this.godotPath}`);
156
+ this.godotPath = null; // Reset to trigger auto-detection later
157
+ }
158
+ }
159
+ }
160
+ // Set the path to the operations script
161
+ this.operationsScriptPath = join(__dirname, 'scripts', 'godot_operations.gd');
162
+ this.editorPluginInstaller = new EditorPluginInstaller(join(__dirname, 'scripts', 'mcp_editor_plugin.gd'));
163
+ this.interactionServerInstaller = new InteractionServerInstaller({
164
+ sourceScriptPath: join(__dirname, 'scripts', 'mcp_interaction_server.gd'),
165
+ logDebug: message => { this.logDebug(message); },
166
+ });
167
+ this.operationRunner = new HeadlessOperationRunner({
168
+ operationsScriptPath: this.operationsScriptPath,
169
+ resolveGodotPath: async () => {
170
+ const path = await this.executable.requirePath();
171
+ if (!path)
172
+ throw new Error('Could not find a valid Godot executable path');
173
+ return path;
174
+ },
175
+ logDebug: message => { this.logDebug(message); },
176
+ debugGodot: debugMode,
177
+ });
178
+ this.authoringSession = new AuthoringSessionManager({
179
+ operationsScriptPath: this.operationsScriptPath,
180
+ resolveGodotPath: async () => {
181
+ const path = await this.executable.requirePath();
182
+ if (!path)
183
+ throw new Error('Could not find a valid Godot executable path');
184
+ return path;
185
+ },
186
+ installer: this.interactionServerInstaller,
187
+ logDebug: message => { this.logDebug(message); },
188
+ // A running user game owns the installed runtime artifacts. Authoring
189
+ // calls use their declared subprocess fallback until that process stops.
190
+ canStart: () => this.activeProcess === null,
191
+ onLifecycleEvent: event => { this.forwardEditorActivity(event); },
192
+ onProjectWrite: event => {
193
+ void this.editorConnection.send('filesystem_changed', { ...event }, 2_000).catch(() => undefined);
194
+ },
195
+ });
196
+ this.headlessOperations = new HeadlessOperationService(this.operationRunner, pathSecurity, this.authoringSession);
197
+ this.gameCommands = new GameCommandService(this.processManager, this.gameConnection);
198
+ if (debugMode)
199
+ console.error(`[DEBUG] Operations script path: ${this.operationsScriptPath}`);
200
+ this.projectSupport = new ProjectSupport({
201
+ getGodotPath: () => this.godotPath,
202
+ detectGodotPath: () => this.detectGodotPath(),
203
+ logDebug: message => { this.logDebug(message); },
204
+ });
205
+ this.gameToolHandlers = new GameToolHandlers({
206
+ commands: this.gameCommands,
207
+ });
208
+ this.projectToolHandlers = new ProjectToolHandlers({
209
+ executable: this.executable,
210
+ logDebug: message => { this.logDebug(message); },
211
+ operations: this.headlessOperations,
212
+ projectSupport: this.projectSupport,
213
+ pathSecurity,
214
+ });
215
+ this.lifecycleToolHandlers = new LifecycleToolHandlers({
216
+ executable: this.executable,
217
+ getActiveProcess: () => this.activeProcess,
218
+ isPathAllowed: projectPath => pathSecurity.isProjectPathAllowed(projectPath),
219
+ isRelativePathAllowed: (projectPath, relativePath) => pathSecurity.isRelativePathAllowed(projectPath, relativePath),
220
+ logDebug: message => { this.logDebug(message); },
221
+ startProjectProcess: (executable, args, onExit, env) => {
222
+ this.processManager.start({
223
+ executable,
224
+ args,
225
+ env,
226
+ onExit,
227
+ onError: error => { console.error('Failed to start Godot process:', error); },
228
+ });
229
+ },
230
+ stopProjectProcess: () => this.processManager.stop(),
231
+ stopAuthoringSession: () => { this.authoringSession.stop(); },
232
+ connectToGame: projectPath => this.connectToGame(projectPath),
233
+ disconnectFromGame: () => { this.disconnectFromGame(); },
234
+ injectInteractionServer: projectPath => { this.injectInteractionServer(projectPath); },
235
+ removeInteractionServer: projectPath => { this.removeInteractionServer(projectPath); },
236
+ getConnectedProjectPath: () => this.gameConnection.connectedProjectPath,
237
+ clearConnectedProjectPath: () => { this.gameConnection.clearConnectedProject(); },
238
+ getInteractionPort: () => this.gameConnection.interactionPort,
239
+ getRuntimeEnvironment: () => ({ GODOT_MCP_RUNTIME_SECRET: RUNTIME_SECRET }),
240
+ installEditorPlugin: projectPath => {
241
+ if (this.editorProjectPath) {
242
+ this.editorPluginInstaller.remove(this.editorProjectPath, this.editorPluginInstallation);
243
+ this.editorPluginInstallation = null;
244
+ }
245
+ this.editorPluginInstallation = this.editorPluginInstaller.install(projectPath);
246
+ this.editorProjectPath = projectPath;
247
+ return this.editorPluginInstallation;
248
+ },
249
+ removeEditorPlugin: (projectPath, installation) => { this.editorPluginInstaller.remove(projectPath, installation); },
250
+ getEditorEnvironment: () => ({ GODOT_MCP_EDITOR_PORT: String(resolveEditorPort()), GODOT_MCP_EDITOR_SECRET: RUNTIME_SECRET }),
251
+ sendEditorCommand: (command, params, timeoutMs) => this.editorConnection.send(command, params, timeoutMs),
252
+ isGameConnected: () => this.gameConnection.isConnected,
253
+ sendGameCommand: (command, params, timeoutMs) => this.gameCommands.send(command, params, timeoutMs),
254
+ dispatchTool: (name, args) => {
255
+ if (!this.toolRegistry)
256
+ throw new Error('Tool registry is not initialized');
257
+ return this.toolRegistry.dispatch(name, args);
258
+ },
259
+ });
260
+ // Initialize the MCP server
261
+ this.server = new McpServer({
262
+ name: 'godot-agent-loop',
263
+ version: SERVER_VERSION,
264
+ }, {
265
+ capabilities: {
266
+ tools: {},
267
+ },
268
+ instructions: SERVER_INSTRUCTIONS,
269
+ });
270
+ // Set up tool handlers
271
+ this.setupToolHandlers();
272
+ // Error handling
273
+ this.server.server.onerror = (error) => { console.error('[MCP Error]', error); };
274
+ // Cleanup on both interactive interruption and process-manager shutdown.
275
+ // E2E clients and service supervisors use SIGTERM; without this handler a
276
+ // persistent authoring child would outlive the MCP server.
277
+ let shuttingDown = false;
278
+ const shutdown = () => {
279
+ if (shuttingDown)
280
+ return;
281
+ shuttingDown = true;
282
+ void this.cleanup().finally(() => { process.exit(0); });
283
+ };
284
+ process.on('SIGINT', shutdown);
285
+ process.on('SIGTERM', shutdown);
286
+ if (process.env.NODE_ENV !== 'test' && !process.env.VITEST) {
287
+ process.stdin.once('end', shutdown);
288
+ }
289
+ }
290
+ forwardEditorActivity(event) {
291
+ void this.editorConnection.send('activity', { ...event }, 1_000).catch(() => undefined);
292
+ }
293
+ /**
294
+ * Log debug messages if debug mode is enabled
295
+ * Using stderr instead of stdout to avoid interfering with JSON-RPC communication
296
+ */
297
+ logDebug(message) {
298
+ if (DEBUG_MODE) {
299
+ console.error(`[DEBUG] ${message}`);
300
+ }
301
+ }
302
+ /**
303
+ * Synchronous validation for constructor use
304
+ * This is a quick check that only verifies file existence, not executable validity
305
+ * Full validation will be performed later in detectGodotPath
306
+ * @param path Path to check
307
+ * @returns True if the path exists or is 'godot' (which might be in PATH)
308
+ */
309
+ isValidGodotPathSync(path) {
310
+ return this.executable.isValidSync(path);
311
+ }
312
+ /**
313
+ * Validate if a Godot path is valid and executable
314
+ */
315
+ async isValidGodotPath(path) {
316
+ return this.executable.isValid(path);
317
+ }
318
+ /**
319
+ * Detect the Godot executable path based on the operating system
320
+ */
321
+ async detectGodotPath() {
322
+ await this.executable.detect();
323
+ }
324
+ /**
325
+ * Set a custom Godot path
326
+ * @param customPath Path to the Godot executable
327
+ * @returns True if the path is valid and was set, false otherwise
328
+ */
329
+ async setGodotPath(customPath) {
330
+ if (!customPath) {
331
+ return false;
332
+ }
333
+ // Normalize the path to ensure consistent format across platforms
334
+ // (e.g., backslashes to forward slashes on Windows, resolving relative paths)
335
+ if (await this.executable.setPath(customPath)) {
336
+ return true;
337
+ }
338
+ this.logDebug(`Failed to set invalid Godot path: ${normalize(customPath)}`);
339
+ return false;
340
+ }
341
+ /**
342
+ * Inject the interaction server script into the Godot project
343
+ */
344
+ injectInteractionServer(projectPath) {
345
+ this.gameConnection.recordInteractionServerInstallation(this.interactionServerInstaller.install(projectPath));
346
+ }
347
+ /**
348
+ * Remove the interaction server script and autoload from the project
349
+ */
350
+ removeInteractionServer(projectPath) {
351
+ const ownedByMcp = this.gameConnection.consumeInteractionServerOwnership();
352
+ this.interactionServerInstaller.remove(projectPath, ownedByMcp);
353
+ }
354
+ /**
355
+ * Connect to the game's TCP interaction server with retries
356
+ */
357
+ async connectToGame(projectPath) {
358
+ await this.gameConnection.connect(projectPath, () => this.activeProcess !== null);
359
+ }
360
+ /**
361
+ * Disconnect from the game interaction server
362
+ */
363
+ disconnectFromGame() {
364
+ this.gameConnection.disconnect();
365
+ }
366
+ rejectAllPending(response) {
367
+ this.gameConnection.rejectAllPending(response);
368
+ }
369
+ resolveGameResponse(parsed) {
370
+ this.gameConnection.resolveResponse(parsed);
371
+ }
372
+ /**
373
+ * Clean up resources when shutting down
374
+ */
375
+ async cleanup() {
376
+ this.logDebug('Cleaning up resources');
377
+ this.authoringSession.stop();
378
+ this.disconnectFromGame();
379
+ this.editorConnection.disconnect();
380
+ if (this.editorProjectPath)
381
+ this.editorPluginInstaller.remove(this.editorProjectPath, this.editorPluginInstallation);
382
+ if (this.gameConnection.connectedProjectPath) {
383
+ this.removeInteractionServer(this.gameConnection.connectedProjectPath);
384
+ this.gameConnection.clearConnectedProject();
385
+ }
386
+ if (this.activeProcess) {
387
+ this.logDebug('Killing active Godot process');
388
+ this.processManager.stop();
389
+ }
390
+ await this.server.close();
391
+ }
392
+ /**
393
+ * Execute a Godot operation using the operations script
394
+ * @param operation The operation to execute
395
+ * @param params The parameters for the operation
396
+ * @param projectPath The path to the Godot project
397
+ * @returns The stdout and stderr from the operation
398
+ */
399
+ async executeOperation(operation, params, projectPath) {
400
+ // Parameter normalization is owned by HeadlessOperationRunner via convertCamelToSnakeCase.
401
+ return this.headlessOperations.execute(operation, params, projectPath);
402
+ }
403
+ // GameToolHandlers owns game_screenshot's type: 'image' / mimeType: 'image/png' response.
404
+ /**
405
+ * Set up the tool handlers for the MCP server
406
+ */
407
+ setupToolHandlers() {
408
+ const tools = new ToolRegistry(createToolHandlers({
409
+ game: this.gameToolHandlers,
410
+ lifecycle: this.lifecycleToolHandlers,
411
+ project: this.projectToolHandlers,
412
+ }), (name, args) => this.editorMutationGuard.check(name, args));
413
+ this.toolRegistry = tools;
414
+ this.server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
415
+ tools: advertisedToolDefinitions(),
416
+ }));
417
+ this.server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
418
+ this.logDebug(`Handling tool request: ${request.params.name}`);
419
+ return tools.dispatch(request.params.name, request.params.arguments);
420
+ });
421
+ }
422
+ async run() {
423
+ try {
424
+ // Detect Godot path before starting the server
425
+ await this.detectGodotPath();
426
+ if (!this.godotPath) {
427
+ console.error('[SERVER] Failed to find a valid Godot executable path');
428
+ console.error('[SERVER] Please set GODOT_PATH environment variable or provide a valid path');
429
+ process.exit(1);
430
+ }
431
+ // Check if the path is valid
432
+ const isValid = await this.isValidGodotPath(this.godotPath);
433
+ if (!isValid) {
434
+ if (this.strictPathValidation) {
435
+ // In strict mode, exit if the path is invalid
436
+ console.error(`[SERVER] Invalid Godot path: ${this.godotPath}`);
437
+ console.error('[SERVER] Please set a valid GODOT_PATH environment variable or provide a valid path');
438
+ process.exit(1);
439
+ }
440
+ else {
441
+ // In compatibility mode, warn but continue with the default path
442
+ console.error(`[SERVER] Warning: Using potentially invalid Godot path: ${this.godotPath}`);
443
+ console.error('[SERVER] This may cause issues when executing Godot commands');
444
+ console.error('[SERVER] This fallback behavior will be removed in a future version. Set strictPathValidation: true to opt-in to the new behavior.');
445
+ }
446
+ }
447
+ console.error(`[SERVER] Using Godot at: ${this.godotPath}`);
448
+ const transport = new StdioServerTransport();
449
+ await this.server.connect(transport);
450
+ console.error('Godot Agent Loop server running on stdio');
451
+ }
452
+ catch (error) {
453
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
454
+ console.error('[SERVER] Failed to start:', errorMessage);
455
+ process.exit(1);
456
+ }
457
+ }
458
+ }
459
+ async function main() {
460
+ const args = process.argv.slice(2);
461
+ if (args[0] === 'setup' && args[1] === 'opencode') {
462
+ await runOpenCodeSetup(args.slice(2));
463
+ return;
464
+ }
465
+ if (args.includes('--help') || args.includes('-h')) {
466
+ console.log('Godot Agent Loop\n\nUsage:\n godot-agent-loop\n godot-agent-loop setup opencode [uninstall] [--scope project|user] [--config PATH] [--write]');
467
+ return;
468
+ }
469
+ const server = new GodotServer();
470
+ await server.run();
471
+ }
472
+ if (process.env.NODE_ENV !== 'test' && !process.env.VITEST) {
473
+ main().catch((error) => {
474
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
475
+ console.error('Godot Agent Loop failed:', errorMessage);
476
+ process.exit(1);
477
+ });
478
+ }