Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Godot MCP enables AI agents to launch the Godot editor, run projects, capture de
- **Launch Godot Editor**: Open the Godot editor for a specific project
- **Run Godot Projects**: Execute Godot projects in debug mode
- **Capture Debug Output**: Retrieve console output and error messages
- **Capture Screenshots**: Grab the active Godot viewport from a running project
- **Control Execution**: Start and stop Godot projects programmatically
- **Get Godot Version**: Retrieve the installed Godot version
- **List Godot Projects**: Find Godot projects in a specified directory
Expand Down Expand Up @@ -123,6 +124,7 @@ Add to your Cline MCP settings file (`~/Library/Application Support/Code/User/gl
"get_godot_version",
"list_projects",
"get_project_info",
"capture_screenshot",
"create_scene",
"add_node",
"load_sprite",
Expand Down Expand Up @@ -194,6 +196,38 @@ For any MCP-compatible client, use this configuration:

</details>

## Example Prompts

Once configured, your AI assistant will automatically run the MCP server when needed. You can use prompts like:

```text
"Launch the Godot editor for my project at /path/to/project"

"Run my Godot project and show me any errors"

"Get information about my Godot project structure"

"Capture a screenshot of my running Godot project"

"Analyze my Godot project structure and suggest improvements"

"Help me debug this error in my Godot project: [paste error]"

"Write a GDScript for a character controller with double jump and wall sliding"

"Create a new scene with a Player node in my Godot project"

"Add a Sprite2D node to my player scene and load the character texture"

"Export my 3D models as a MeshLibrary for use with GridMap"

"Create a UI scene with buttons and labels for my game's main menu"

"Get the UID for a specific script file in my Godot 4.4 project"

"Update UID references in my Godot project after upgrading to 4.4"
```

### Environment Variables

| Variable | Description |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"build": "tsc && node scripts/build.js",
"inspector": "npx @modelcontextprotocol/inspector build/index.js",
"prepare": "npm run build",
"test": "node --test test/**/*.test.mjs",
"watch": "tsc --watch"
},
"dependencies": {
Expand Down
215 changes: 212 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { fileURLToPath } from 'url';
import { join, dirname, basename, normalize } from 'path';
import { existsSync, readdirSync, mkdirSync } from 'fs';
import { existsSync, readdirSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
import { spawn, execFile } from 'child_process';
import { promisify } from 'util';

Expand All @@ -32,13 +32,52 @@ const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const SCREENSHOT_WRAPPER_SCRIPT = `extends Node

const REAL_MAIN_SCENE = "REPLACE_WITH_MAIN_SCENE_PATH"
const TRIGGER_FILE = "res://.mcp_screenshot_req"
const OUTPUT_FILE = "res://.mcp_screenshot.png"

func _ready():
\tprint("[MCP] Wrapper loaded. Starting main scene: ", REAL_MAIN_SCENE)
\tif ResourceLoader.exists(REAL_MAIN_SCENE):
\t\tvar main_packed = load(REAL_MAIN_SCENE)
\t\tvar main_inst = main_packed.instantiate()
\t\tadd_child.call_deferred(main_inst)
\telse:
\t\tprinterr("[MCP] Error: Could not find main scene at ", REAL_MAIN_SCENE)

func _process(_delta):
\tif FileAccess.file_exists(TRIGGER_FILE):
\t\t_take_screenshot()
\t\tDirAccess.remove_absolute(TRIGGER_FILE)

func _take_screenshot():
\tprint("[MCP] Capturing screenshot...")
\tvar image = get_viewport().get_texture().get_image()
\tvar error = image.save_png(OUTPUT_FILE)
\tif error == OK:
\t\tprint("[MCP] Screenshot saved to ", OUTPUT_FILE)
\telse:
\t\tprinterr("[MCP] Failed to save screenshot. Error code: ", error)
`;

const SCREENSHOT_WRAPPER_SCENE = `[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://.mcp_wrapper.gd" id="1_mcp"]

[node name="MCPWrapper" type="Node"]
script = ExtResource("1_mcp")
`;

/**
* Interface representing a running Godot process
*/
interface GodotProcess {
process: any;
output: string[];
errors: string[];
projectPath: string;
}

/**
Expand Down Expand Up @@ -385,12 +424,74 @@ class GodotServer {
this.logDebug('Cleaning up resources');
if (this.activeProcess) {
this.logDebug('Killing active Godot process');
this.cleanupScreenshotArtifacts(this.activeProcess.projectPath);
this.activeProcess.process.kill();
this.activeProcess = null;
}
await this.server.close();
}

private tryResolveMainScene(projectPath: string, scene?: string): string | null {
let sceneToRun: string | null = scene?.trim() ?? null;

if (!sceneToRun) {
try {
const projectFile = join(projectPath, 'project.godot');
const projectContent = readFileSync(projectFile, 'utf-8');
const match = projectContent.match(/run\/main_scene="([^"]+)"/);
sceneToRun = match?.[1] ?? null;
} catch (error) {
this.logDebug(`Failed to resolve main scene from project.godot: ${error}`);
sceneToRun = null;
}
}

if (!sceneToRun) {
return null;
}

return sceneToRun.startsWith('res://')
? sceneToRun
: `res://${sceneToRun.replace(/\\/g, '/')}`;
}

private initializeScreenshotWrapper(projectPath: string, scenePath: string): boolean {
try {
const wrapperScriptPath = join(projectPath, '.mcp_wrapper.gd');
const wrapperScenePath = join(projectPath, '.mcp_wrapper.tscn');
const wrapperScriptContent = SCREENSHOT_WRAPPER_SCRIPT.replace('REPLACE_WITH_MAIN_SCENE_PATH', scenePath);

writeFileSync(wrapperScriptPath, wrapperScriptContent, 'utf8');
writeFileSync(wrapperScenePath, SCREENSHOT_WRAPPER_SCENE, 'utf8');

this.logDebug(`Initialized screenshot wrapper for scene: ${scenePath}`);
return true;
} catch (error) {
this.logDebug(`Failed to initialize screenshot wrapper: ${error}`);
return false;
}
}

private cleanupScreenshotArtifacts(projectPath: string): void {
try {
const files = [
'.mcp_wrapper.gd',
'.mcp_wrapper.tscn',
'.mcp_screenshot_req',
'.mcp_screenshot.png',
];

for (const file of files) {
const filePath = join(projectPath, file);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}
} catch (error) {
this.logDebug(`Failed to clean screenshot artifacts: ${error}`);
}
}

/**
* Check if the Godot version is 4.4 or later
* @param version The Godot version string
Expand Down Expand Up @@ -748,6 +849,20 @@ class GodotServer {
required: ['projectPath'],
},
},
{
name: 'capture_screenshot',
description: 'Capture a screenshot of the running Godot project',
inputSchema: {
type: 'object',
properties: {
projectPath: {
type: 'string',
description: 'Path to the Godot project directory',
},
},
required: ['projectPath'],
},
},
{
name: 'create_scene',
description: 'Create a new Godot scene file',
Expand Down Expand Up @@ -934,6 +1049,8 @@ class GodotServer {
return await this.handleListProjects(request.params.arguments);
case 'get_project_info':
return await this.handleGetProjectInfo(request.params.arguments);
case 'capture_screenshot':
return await this.handleCaptureScreenshot(request.params.arguments);
case 'create_scene':
return await this.handleCreateScene(request.params.arguments);
case 'add_node':
Expand Down Expand Up @@ -1074,11 +1191,18 @@ class GodotServer {
// Kill any existing process
if (this.activeProcess) {
this.logDebug('Killing existing Godot process before starting a new one');
this.cleanupScreenshotArtifacts(this.activeProcess.projectPath);
this.activeProcess.process.kill();
}

this.cleanupScreenshotArtifacts(args.projectPath);

const sceneToRun = this.tryResolveMainScene(args.projectPath, args.scene);
const cmdArgs = ['-d', '--path', args.projectPath];
if (args.scene && this.validatePath(args.scene)) {

if (sceneToRun && this.initializeScreenshotWrapper(args.projectPath, sceneToRun)) {
cmdArgs.push('.mcp_wrapper.tscn');
} else if (args.scene && this.validatePath(args.scene)) {
this.logDebug(`Adding scene parameter: ${args.scene}`);
cmdArgs.push(args.scene);
}
Expand Down Expand Up @@ -1118,7 +1242,7 @@ class GodotServer {
}
});

this.activeProcess = { process, output, errors };
this.activeProcess = { process, output, errors, projectPath: args.projectPath };

return {
content: [
Expand Down Expand Up @@ -1187,6 +1311,7 @@ class GodotServer {
}

this.logDebug('Stopping active Godot process');
this.cleanupScreenshotArtifacts(this.activeProcess.projectPath);
this.activeProcess.process.kill();
const output = this.activeProcess.output;
const errors = this.activeProcess.errors;
Expand Down Expand Up @@ -1466,6 +1591,90 @@ class GodotServer {
}
}

/**
* Handle the capture_screenshot tool
*/
private async handleCaptureScreenshot(args: any) {
args = this.normalizeParameters(args);

if (!args.projectPath) {
return this.createErrorResponse(
'Project path is required',
['Provide a valid path to a Godot project directory']
);
}

if (!this.activeProcess) {
return this.createErrorResponse(
'No active Godot process.',
[
'Use run_project to start a Godot project first',
'Screenshots can only be taken from a running project started by this server',
]
);
}

if (normalize(args.projectPath) !== normalize(this.activeProcess.projectPath)) {
return this.createErrorResponse(
'Requested project does not match the active Godot process.',
[
'Pass the same projectPath that was used with run_project',
'Stop the current project and start the target project first',
]
);
}

try {
const triggerFile = join(args.projectPath, '.mcp_screenshot_req');
const outputFile = join(args.projectPath, '.mcp_screenshot.png');

if (existsSync(outputFile)) {
unlinkSync(outputFile);
}

writeFileSync(triggerFile, '', 'utf8');
this.logDebug('Created screenshot trigger file');

for (let attempt = 0; attempt < 50; attempt++) {
if (existsSync(outputFile)) {
await new Promise((resolve) => setTimeout(resolve, 100));

return {
content: [
{
type: 'text',
text: `Screenshot captured successfully: ${outputFile}`,
},
{
type: 'image',
data: readFileSync(outputFile).toString('base64'),
mimeType: 'image/png',
},
],
};
}

await new Promise((resolve) => setTimeout(resolve, 100));
}

return this.createErrorResponse(
'Timed out waiting for screenshot.',
[
'Ensure the project is running correctly',
'Check if the wrapper scene was loaded successfully',
]
);
} catch (error: any) {
return this.createErrorResponse(
`Failed to capture screenshot: ${error?.message || 'Unknown error'}`,
[
'Ensure the project path is accessible',
'Check file permissions',
]
);
}
}

/**
* Handle the create_scene tool
*/
Expand Down
Loading