@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.
- package/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- 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
|
+
}
|