#!/usr/bin/env node /** * godot-cli — Command-line interface for Godot MCP Pro * * Connects directly to the Godot editor plugin via WebSocket (JSON-RPC 2.0). * Designed for LLMs that can use bash/terminal tools but have tight MCP tool limits. * Progressive disclosure via --help at each command level. */ import { WebSocketServer } from "ws"; import { randomUUID } from "crypto"; import { createServer } from "net"; const BASE_PORT = 6510; const MAX_PORT = 6514; const CONNECT_TIMEOUT_MS = 10000; const COMMAND_TIMEOUT_MS = 30000; const COMMANDS = { project: { description: "Project info, files, and settings", commands: { info: { description: "Get project metadata (name, version, viewport, renderer, autoloads)", method: "get_project_info", }, files: { description: "List project file/directory tree", method: "get_filesystem_tree", args: { path: { description: "Root path (default: res://)" }, filter: { description: "Glob filter (e.g. '*.gd', '*.tscn')" }, }, }, search: { description: "Search for files by name pattern", method: "search_files", args: { query: { description: "Search query (fuzzy match or glob)", required: true }, path: { description: "Directory to search in" }, file_type: { description: "Filter by extension (e.g. 'gd', 'tscn')" }, }, }, grep: { description: "Search inside file contents", method: "search_in_files", args: { query: { description: "Text/regex pattern", required: true }, path: { description: "Directory to search in" }, file_type: { description: "File extension filter (e.g. 'gd', 'tscn')" }, }, }, "get-setting": { description: "Get project settings", method: "get_project_settings", args: { category: { description: "Settings category filter" }, }, }, "set-setting": { description: "Set a project setting", method: "set_project_setting", args: { setting: { description: "Setting path (e.g. display/window/size/viewport_width)", required: true }, value: { description: "Value to set", required: true }, }, mapArgs: (p) => ({ setting: p.setting, value: autoType(p.value) }), }, }, }, scene: { description: "Scene tree and scene management", commands: { tree: { description: "Get the current scene tree", method: "get_scene_tree", args: { max_depth: { description: "Maximum depth to display", type: "number" }, }, mapArgs: (p) => (p.max_depth ? { max_depth: parseInt(p.max_depth) } : {}), }, create: { description: "Create a new scene with a root node", method: "create_scene", args: { path: { description: "Scene path (e.g. res://scenes/player.tscn)", required: true }, root_type: { description: "Root node type (default: Node2D)" }, root_name: { description: "Root node name" }, }, }, open: { description: "Open a scene in the editor", method: "open_scene", args: { path: { description: "Scene path to open", required: true }, }, }, save: { description: "Save the current scene", method: "save_scene", args: { path: { description: "Optional path to save as" }, }, }, play: { description: "Run the current/specified scene", method: "play_scene", args: { mode: { description: "'main' (default), 'current', or a scene path" }, }, }, stop: { description: "Stop the running scene", method: "stop_scene", }, content: { description: "Get scene file content (tscn format parsed)", method: "get_scene_file_content", args: { path: { description: "Scene file path", required: true }, }, }, exports: { description: "Get exported variables of a scene", method: "get_scene_exports", args: { path: { description: "Scene file path", required: true }, }, }, delete: { description: "Delete a scene file", method: "delete_scene", args: { path: { description: "Scene file path to delete", required: true }, }, }, instance: { description: "Add a scene instance as child node", method: "add_scene_instance", args: { scene_path: { description: "Path to .tscn file to instance", required: true }, parent_path: { description: "Parent node path (default: selected/root)" }, name: { description: "Instance name" }, }, }, }, }, node: { description: "Add, modify, and delete scene nodes", commands: { add: { description: "Add a new node to the scene", method: "add_node", args: { type: { description: "Node type (e.g. CharacterBody3D, Sprite2D)", required: true }, name: { description: "Node name" }, parent_path: { description: "Parent node path (default: root '.')" }, }, }, delete: { description: "Delete a node from the scene", method: "delete_node", args: { node_path: { description: "Node path to delete", required: true }, }, }, get: { description: "Get all properties of a node", method: "get_node_properties", args: { node_path: { description: "Node path", required: true }, }, }, set: { description: "Set a property on a node", method: "update_property", args: { node_path: { description: "Node path", required: true }, property: { description: "Property name", required: true }, value: { description: "Value to set", required: true }, }, mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }), }, duplicate: { description: "Duplicate a node", method: "duplicate_node", args: { node_path: { description: "Node path to duplicate", required: true }, name: { description: "Name for the duplicate" }, }, }, move: { description: "Move/reparent a node", method: "move_node", args: { node_path: { description: "Node path to move", required: true }, new_parent_path: { description: "New parent path", required: true }, }, }, rename: { description: "Rename a node", method: "rename_node", args: { node_path: { description: "Node path", required: true }, new_name: { description: "New name", required: true }, }, }, connect: { description: "Connect a signal between nodes", method: "connect_signal", args: { source_path: { description: "Source node path", required: true }, signal_name: { description: "Signal name", required: true }, target_path: { description: "Target node path", required: true }, method_name: { description: "Target method name", required: true }, }, }, groups: { description: "Get groups a node belongs to", method: "get_node_groups", args: { node_path: { description: "Node path", required: true }, }, }, }, }, script: { description: "Read, create, and edit GDScript/C# files", commands: { read: { description: "Read a script file", method: "read_script", args: { path: { description: "Script path (e.g. res://player.gd)", required: true }, }, }, create: { description: "Create a new script file (.gd or .cs only)", method: "create_script", args: { path: { description: "Script path", required: true }, content: { description: "Script content", required: true }, base_type: { description: "Base class (default: Node)" }, force: { description: "Override open-script-editor guard" }, }, mapArgs: (p) => { const r = { path: p.path, content: p.content }; if (p.base_type) r.base_type = p.base_type; if (p.force !== undefined) r.force = p.force === "true"; return r; }, }, edit: { description: "Edit an existing script (full replace or 1-based inclusive line range)", method: "edit_script", args: { path: { description: "Script path", required: true }, content: { description: "New content", required: true }, start_line: { description: "Start line for partial edit (1-based inclusive)", type: "number" }, end_line: { description: "End line for partial edit (1-based inclusive)", type: "number" }, force: { description: "Override open-script-editor guard" }, }, mapArgs: (p) => { const r = { path: p.path, content: p.content }; if (p.start_line) r.start_line = parseInt(p.start_line); if (p.end_line) r.end_line = parseInt(p.end_line); if (p.force !== undefined) r.force = p.force === "true"; return r; }, }, attach: { description: "Attach a script to a node", method: "attach_script", args: { node_path: { description: "Node path", required: true }, script_path: { description: "Script path", required: true }, }, }, validate: { description: "Validate a GDScript for errors", method: "validate_script", args: { path: { description: "Script path to validate", required: true }, }, }, list: { description: "List all scripts in the project", method: "list_scripts", }, }, }, editor: { description: "Editor state, errors, screenshots, and utilities", commands: { errors: { description: "Get current editor errors/warnings", method: "get_editor_errors", }, log: { description: "Get editor output log", method: "get_output_log", args: { lines: { description: "Number of lines (default: 50)", type: "number" }, }, mapArgs: (p) => (p.lines ? { lines: parseInt(p.lines) } : {}), }, screenshot: { description: "Take a screenshot of the running game", method: "get_game_screenshot", }, "editor-screenshot": { description: "Take a screenshot of the editor", method: "get_editor_screenshot", }, exec: { description: "Execute an editor script (GDScript in editor context)", method: "execute_editor_script", args: { code: { description: "GDScript code to execute", required: true }, }, }, signals: { description: "Get signals of a node type", method: "get_signals", args: { node_path: { description: "Node path", required: true }, }, }, reload: { description: "Reload the project", method: "reload_project", }, }, }, input: { description: "Simulate keyboard, mouse, and input actions", commands: { key: { description: "Simulate a key press in the running game", method: "simulate_key", args: { key: { description: "Key name (e.g. W, A, S, D, Space)", required: true }, duration: { description: "Hold duration in seconds", type: "number" }, pressed: { description: "true=press, false=release" }, }, mapArgs: (p) => { const r = { key: p.key }; if (p.duration) r.duration = parseFloat(p.duration); if (p.pressed !== undefined) r.pressed = p.pressed === "true"; return r; }, }, click: { description: "Simulate a mouse click in the running game", method: "simulate_mouse_click", args: { x: { description: "X coordinate", required: true, type: "number" }, y: { description: "Y coordinate", required: true, type: "number" }, button: { description: "Mouse button: left, right, or middle (default: left)" }, }, mapArgs: (p) => { const buttonMap = { left: 1, right: 2, middle: 3 }; const r = { x: parseInt(p.x), y: parseInt(p.y) }; if (p.button) r.button = buttonMap[p.button.toLowerCase()] ?? (parseInt(p.button) || 1); return r; }, }, action: { description: "Simulate an input action (as defined in Input Map)", method: "simulate_action", args: { action: { description: "Action name (e.g. ui_accept, move_left)", required: true }, pressed: { description: "true=press, false=release" }, duration: { description: "Hold duration in seconds", type: "number" }, }, mapArgs: (p) => { const r = { action: p.action }; if (p.pressed !== undefined) r.pressed = p.pressed === "true"; if (p.duration) r.duration = parseFloat(p.duration); return r; }, }, actions: { description: "List all configured input actions", method: "get_input_actions", }, }, }, runtime: { description: "Inspect and control the running game", commands: { tree: { description: "Get the running game's scene tree", method: "get_game_scene_tree", args: { max_depth: { description: "Max depth (-1 for unlimited)", type: "number" }, }, mapArgs: (p) => { const r = {}; if (p.max_depth) r.max_depth = parseInt(p.max_depth); return r; }, }, get: { description: "Get properties of a node in the running game", method: "get_game_node_properties", args: { node_path: { description: "Node path", required: true }, properties: { description: "Comma-separated property names (default: all)" }, }, mapArgs: (p) => { const r = { node_path: p.node_path }; if (p.properties) r.properties = p.properties.split(",").map(s => s.trim()); return r; }, }, set: { description: "Set a property on a running game node", method: "set_game_node_property", args: { node_path: { description: "Node path", required: true }, property: { description: "Property name", required: true }, value: { description: "Value to set", required: true }, }, mapArgs: (p) => ({ node_path: p.node_path, property: p.property, value: autoType(p.value) }), }, exec: { description: "Execute GDScript in the running game", method: "execute_game_script", args: { code: { description: "GDScript code", required: true }, node_path: { description: "Node context (default: /root)" }, }, }, ui: { description: "Find UI elements (buttons, labels, etc.) in the running game", method: "find_ui_elements", args: { type_filter: { description: "Filter by type (Button, Label, etc.)" }, }, }, }, }, }; // ─── Argument parsing ───────────────────────────────────────────────── function autoType(value) { if (value === "true") return true; if (value === "false") return false; if (value === "null") return null; const num = Number(value); if (!isNaN(num) && value.trim() !== "") return num; // Try JSON for arrays/objects if ((value.startsWith("[") || value.startsWith("{")) && (value.endsWith("]") || value.endsWith("}"))) { try { return JSON.parse(value); } catch { /* fall through */ } } return value; } function parseArgs(argv) { const positional = []; const flags = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg.startsWith("--")) { const key = arg.slice(2); const next = argv[i + 1]; if (next && !next.startsWith("--")) { flags[key] = next; i++; } else { flags[key] = "true"; } } else { positional.push(arg); } } return { positional, flags }; } // ─── Help formatting ────────────────────────────────────────────────── function showMainHelp() { console.log(`godot-cli — Control Godot editor from the command line Usage: godot-cli [options] Groups:`); for (const [name, group] of Object.entries(COMMANDS)) { console.log(` ${name.padEnd(12)} ${group.description}`); } console.log(` Options: --port Godot WebSocket port (default: auto-detect 6510-6514) --help Show help for a group or command Examples: godot-cli project info godot-cli scene tree godot-cli node add --type CharacterBody3D --name Player godot-cli script read --path res://player.gd godot-cli scene play godot-cli input key --key W --duration 0.5`); } function showGroupHelp(groupName, group) { console.log(`godot-cli ${groupName} — ${group.description} Commands:`); for (const [name, cmd] of Object.entries(group.commands)) { console.log(` ${name.padEnd(18)} ${cmd.description}`); } console.log(`\nUse: godot-cli ${groupName} --help for details`); } function showCommandHelp(groupName, cmdName, cmd) { console.log(`godot-cli ${groupName} ${cmdName} — ${cmd.description}`); if (cmd.args && Object.keys(cmd.args).length > 0) { console.log(`\nOptions:`); for (const [name, arg] of Object.entries(cmd.args)) { const req = arg.required ? " (required)" : ""; console.log(` --${name.padEnd(16)} ${arg.description}${req}`); } } } // ─── WebSocket connection ───────────────────────────────────────────── // The Godot plugin is a WebSocket CLIENT that connects to servers on ports 6505-6514. // The CLI starts a temporary WebSocket SERVER on an available port and waits for // the Godot plugin to connect (it polls every 3 seconds). function isPortFree(port) { return new Promise((resolve) => { const server = createServer(); server.once("error", () => resolve(false)); server.once("listening", () => { server.close(() => resolve(true)); }); server.listen(port, "127.0.0.1"); }); } async function findFreePort(preferredPort) { if (preferredPort) { if (await isPortFree(preferredPort)) return preferredPort; return null; } for (let p = BASE_PORT; p <= MAX_PORT; p++) { if (await isPortFree(p)) return p; } return null; } /** * Start a WebSocket server and wait for the Godot plugin to connect. * Returns the connected client WebSocket and the server (for cleanup). */ function waitForGodot(port) { return new Promise((resolve, reject) => { const wss = new WebSocketServer({ port, host: "127.0.0.1" }); const timeout = setTimeout(() => { wss.close(); reject(new Error(`Godot plugin did not connect within ${CONNECT_TIMEOUT_MS / 1000}s.\n` + "Make sure the Godot editor is running with the MCP plugin enabled.")); }, CONNECT_TIMEOUT_MS); wss.on("error", (err) => { clearTimeout(timeout); reject(err); }); wss.on("connection", (ws) => { clearTimeout(timeout); resolve({ client: ws, wss }); }); }); } function sendCommand(ws, method, params) { return new Promise((resolve, reject) => { const id = randomUUID(); const timeout = setTimeout(() => { reject(new Error(`Command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms`)); }, COMMAND_TIMEOUT_MS); const handler = (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch { return; } // Ignore ping/pong if (msg.method === "pong" || msg.method === "ping") return; if (msg.id !== id) return; clearTimeout(timeout); ws.off("message", handler); if (msg.error) { reject(new Error(`Godot error: ${msg.error.message || JSON.stringify(msg.error)}`)); } else { resolve(msg.result); } }; ws.on("message", handler); ws.send(JSON.stringify({ jsonrpc: "2.0", method, params, id })); }); } // ─── Main ───────────────────────────────────────────────────────────── async function main() { const userArgs = process.argv.slice(2); const { positional, flags } = parseArgs(userArgs); // Global --help if (positional.length === 0 || flags.help === "true" && positional.length === 0) { showMainHelp(); process.exit(0); } const groupName = positional[0]; const group = COMMANDS[groupName]; if (!group) { console.error(`Unknown group: ${groupName}`); showMainHelp(); process.exit(1); } // Group-level --help if (positional.length === 1 || (flags.help === "true" && positional.length === 1)) { showGroupHelp(groupName, group); process.exit(0); } const cmdName = positional[1]; const cmd = group.commands[cmdName]; if (!cmd) { console.error(`Unknown command: ${groupName} ${cmdName}`); showGroupHelp(groupName, group); process.exit(1); } // Command-level --help if (flags.help === "true") { showCommandHelp(groupName, cmdName, cmd); process.exit(0); } // Validate required args if (cmd.args) { for (const [name, arg] of Object.entries(cmd.args)) { if (arg.required && !flags[name]) { console.error(`Missing required option: --${name}`); showCommandHelp(groupName, cmdName, cmd); process.exit(1); } } } // Build params const params = cmd.mapArgs ? cmd.mapArgs(flags) : { ...flags }; // Remove internal flags delete params.port; delete params.help; // Connect and execute const preferredPort = flags.port ? parseInt(flags.port) : undefined; const port = await findFreePort(preferredPort); if (!port) { console.error(`No free ports in range ${BASE_PORT}-${MAX_PORT}.\n` + "All ports are occupied by MCP server instances."); process.exit(1); } let client; let wss; try { process.stderr.write(`Waiting for Godot on port ${port}...`); ({ client, wss } = await waitForGodot(port)); process.stderr.write(" connected!\n"); } catch (err) { process.stderr.write("\n"); console.error(err.message); process.exit(1); } try { const result = await sendCommand(client, cmd.method, params); if (result !== undefined && result !== null) { console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)); } } catch (err) { console.error(err.message); process.exit(1); } finally { client.close(); wss.close(); } } main().catch((err) => { console.error("Fatal:", err.message); process.exit(1); }); //# sourceMappingURL=cli.js.map