91b878f347
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle) - Created weapon resource files with balanced stats - Wired Pistol + Rifle into player.tscn replacing Kenney blasters - Fixed WASD input (matched input map action names) - Fixed Escape key toggles mouse capture - Added Audio autoload singleton - Fixed broken sound asset paths across all scripts - Fixed all scene ext_resource text paths (nested under assets/kenney-fps/) - Fixed all .import source_file paths - Deleted stale .import files (will be regenerated by editor on open)
948 lines
48 KiB
JavaScript
948 lines
48 KiB
JavaScript
import { join, sep, resolve } from 'path';
|
||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||
import { normalizeParameters, validateProjectArgs, validateSubPath, createErrorResponse, createStructuredResponse, getErrorMessage, isUnderDir, BRIDGE_WAIT_SPAWNED_TIMEOUT_MS, } from '../utils/godot-runner.js';
|
||
import { attachRuntimeWarnings, parseBridgeJson, MAX_RUNTIME_ERROR_CONTEXT_LINES, } from '../utils/handler-helpers.js';
|
||
import { logDebug } from '../utils/logger.js';
|
||
import { randomUUID } from 'crypto';
|
||
const SCREENSHOT_RESPONSE_MODES = ['full', 'preview', 'path_only'];
|
||
const DEFAULT_PREVIEW_MAX_WIDTH = 960;
|
||
const DEFAULT_PREVIEW_MAX_HEIGHT = 540;
|
||
// --- Tool definitions ---
|
||
export const runtimeToolDefinitions = [
|
||
{
|
||
name: 'launch_editor',
|
||
description: 'Open the Godot editor GUI for a project for the human user. Use only when the user explicitly asks to "open the editor"; for any agent-driven work, use the headless scene/node tools (add_node, set_node_properties, etc.) instead — the editor cannot be controlled programmatically. Returns plain-text confirmation after spawning the editor process. Errors if projectPath has no project.godot.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
projectPath: {
|
||
type: 'string',
|
||
description: 'Path to the Godot project directory',
|
||
},
|
||
},
|
||
required: ['projectPath'],
|
||
},
|
||
},
|
||
{
|
||
name: 'run_project',
|
||
description: 'Spawn a Godot project as a child process with stdout/stderr captured. Required before take_screenshot, simulate_input, get_ui_elements, run_script, or get_debug_output. For a Godot process you launched yourself, use attach_project instead. Verifies MCP bridge readiness before returning success. Returns plain-text status with the assigned bridge port. Call stop_project when done. Errors if projectPath is not a Godot project or another session is already active.',
|
||
annotations: { destructiveHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
projectPath: {
|
||
type: 'string',
|
||
description: 'Path to the Godot project directory',
|
||
},
|
||
scene: {
|
||
type: 'string',
|
||
description: 'Scene to run (path relative to project, e.g. "scenes/main.tscn"). Omit to use the project\'s main scene.',
|
||
},
|
||
background: {
|
||
type: 'boolean',
|
||
description: 'If true, hides the Godot window off-screen and blocks all physical keyboard and mouse input, while keeping programmatic input (simulate_input, run_script) and screenshots fully active. Useful for automated agent-driven testing where the window should not be visible or interactive.',
|
||
},
|
||
bridgePort: {
|
||
type: 'number',
|
||
minimum: 1,
|
||
maximum: 65535,
|
||
description: "TCP port for the MCP bridge. Omit to auto-select a free port (recommended). The chosen port is baked into the project's `mcp_bridge.gd` at inject time, so the running Godot listens on exactly this port.",
|
||
},
|
||
},
|
||
required: ['projectPath'],
|
||
},
|
||
},
|
||
{
|
||
name: 'attach_project',
|
||
description: 'Inject the MCP bridge into a Godot process you launch yourself, then wait up to 15s for the bridge to respond. Call BEFORE Godot launches — Godot reads autoloads only at process start, so a late call returns "bridge did not respond." Recommended pattern: kick off the Godot launch in parallel with this call so the wait absorbs startup. Prefer run_project unless MCP must not spawn Godot. Returns plain-text status with the resolved bridge port. Call detach_project or stop_project when done.',
|
||
annotations: { destructiveHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
projectPath: {
|
||
type: 'string',
|
||
description: 'Path to the Godot project directory',
|
||
},
|
||
bridgePort: {
|
||
type: 'number',
|
||
minimum: 1,
|
||
maximum: 65535,
|
||
description: "TCP port for the MCP bridge. Omit to auto-select a free port (recommended). The chosen port is baked into the project's `mcp_bridge.gd` at inject time, so the running Godot listens on exactly this port.",
|
||
},
|
||
},
|
||
required: ['projectPath'],
|
||
},
|
||
},
|
||
{
|
||
name: 'detach_project',
|
||
description: 'Clear attached-mode runtime state and remove the injected McpBridge autoload. Does NOT stop the manually launched Godot process — that stays running. Use after attach_project when you are done driving the game from MCP. For spawned sessions (run_project), use stop_project instead. Returns: message confirming detach plus externalProcessPreserved (always true here — that is the point of detach vs stop_project). Errors if called outside an attached session.',
|
||
annotations: { destructiveHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {},
|
||
required: [],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
message: { type: 'string' },
|
||
externalProcessPreserved: { type: 'boolean' },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'get_debug_output',
|
||
description: 'Get captured stdout/stderr from a spawned Godot project. Use whenever runtime tools fail unexpectedly — script errors, missing nodes, and crash backtraces all surface here. Requires run_project (not attach_project; attached mode does not capture output). Returns: output/errors (last `limit` lines each, default 200), running (false after exit, null when attached), exitCode after exit, attached:true with empty arrays in attached mode.',
|
||
annotations: { readOnlyHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
limit: {
|
||
type: 'number',
|
||
description: 'Max lines to return (default: 200, from end of output)',
|
||
},
|
||
},
|
||
required: [],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
output: { type: 'array', items: { type: 'string' } },
|
||
errors: { type: 'array', items: { type: 'string' } },
|
||
running: { type: ['boolean', 'null'] },
|
||
exitCode: { type: ['number', 'null'] },
|
||
attached: { type: 'boolean' },
|
||
tip: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'stop_project',
|
||
description: 'Stop the spawned Godot project and clean up MCP bridge state. Always call when done with runtime testing — even after a crash — to free the single process slot so run_project can be called again. For attached sessions, this detaches without killing the externally launched process. Returns: message, mode ("spawned"/"attached"), externalProcessPreserved (true only for attached), finalOutput and finalErrors (last 200 lines each). Errors if no session is active.',
|
||
annotations: { destructiveHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {},
|
||
required: [],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
message: { type: 'string' },
|
||
mode: { type: 'string' },
|
||
externalProcessPreserved: { type: 'boolean' },
|
||
finalOutput: { type: 'array', items: { type: 'string' } },
|
||
finalErrors: { type: 'array', items: { type: 'string' } },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'take_screenshot',
|
||
description: 'Capture a PNG of the running viewport. responseMode: preview (default — saves full PNG, returns bounded inline preview at 960x540), full (full inline PNG; use for small text or pixel-level inspection), path_only (saved-path only, no inline image). Saved under .mcp/screenshots. Returns: inline image block (full/preview modes), plus path and size of the saved PNG; previewPath/previewSize in preview mode; warnings for non-fatal runtime errors. Errors if no session or bridge times out (default 10000ms).',
|
||
annotations: { readOnlyHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
timeout: {
|
||
type: 'number',
|
||
description: 'Timeout in milliseconds to wait for the screenshot (default: 10000)',
|
||
},
|
||
responseMode: {
|
||
type: 'string',
|
||
enum: ['full', 'preview', 'path_only'],
|
||
description: 'Response payload mode. "preview" returns a bounded inline preview plus paths (default). "full" returns the full inline PNG. "path_only" returns paths only.',
|
||
},
|
||
previewMaxWidth: {
|
||
type: 'number',
|
||
description: 'Maximum preview width in pixels when responseMode is "preview" (default: 960)',
|
||
},
|
||
previewMaxHeight: {
|
||
type: 'number',
|
||
description: 'Maximum preview height in pixels when responseMode is "preview" (default: 540)',
|
||
},
|
||
},
|
||
required: [],
|
||
},
|
||
// The handler also emits an inline `image` content block for full/preview modes;
|
||
// outputSchema only describes the structured JSON text payload per MCP spec.
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
responseMode: { type: 'string' },
|
||
path: { type: 'string' },
|
||
size: {
|
||
type: 'object',
|
||
properties: {
|
||
width: { type: 'number' },
|
||
height: { type: 'number' },
|
||
},
|
||
},
|
||
previewPath: { type: 'string' },
|
||
previewSize: {
|
||
type: 'object',
|
||
properties: {
|
||
width: { type: 'number' },
|
||
height: { type: 'number' },
|
||
},
|
||
},
|
||
warnings: { type: 'array', items: { type: 'string' } },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'simulate_input',
|
||
description: "Simulate sequential input in a running project. Each action's `type` (key, mouse_button, mouse_motion, click_element, action, wait) gates which other fields apply — see per-property docs. For click_element use get_ui_elements first; resolution is by path/name, not visible text. Press/release require two actions; insert wait between for frame ticks. Returns: success, actions_processed, warnings for runtime errors fired by input handlers. Errors if no session or any action fails validation.",
|
||
annotations: { destructiveHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
actions: {
|
||
type: 'array',
|
||
description: 'Array of input actions to execute sequentially. Each object must have a "type" field.',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
type: {
|
||
type: 'string',
|
||
enum: ['key', 'mouse_button', 'mouse_motion', 'click_element', 'action', 'wait'],
|
||
description: 'The type of input action',
|
||
},
|
||
key: {
|
||
type: 'string',
|
||
description: '[key] Godot KEY_* constant name without the prefix (e.g. "W", "Space", "Escape", "Enter", "Tab", "Up", "PageUp"). Errors on unrecognized names.',
|
||
},
|
||
pressed: {
|
||
type: 'boolean',
|
||
description: '[key, mouse_button, action] Whether the input is pressed (true) or released (false). For mouse_button: omit to auto-click (press+release in one action); set explicitly only for hold/release. For key: defaults to true and does NOT auto-release — emit a second action with pressed:false to release.',
|
||
},
|
||
shift: { type: 'boolean', description: '[key] Shift modifier' },
|
||
ctrl: { type: 'boolean', description: '[key] Ctrl modifier' },
|
||
alt: { type: 'boolean', description: '[key] Alt modifier' },
|
||
unicode: {
|
||
type: 'number',
|
||
description: '[key] Unicode codepoint for text-entry Controls (LineEdit, TextEdit). Auto-derived for ASCII letters/digits (respecting shift); pass explicitly for symbols or non-ASCII. E.g. 33 for "!", 64 for "@".',
|
||
},
|
||
button: {
|
||
type: 'string',
|
||
enum: ['left', 'right', 'middle'],
|
||
description: '[mouse_button, click_element] Mouse button (default: left)',
|
||
},
|
||
x: {
|
||
type: 'number',
|
||
description: '[mouse_button, mouse_motion] X position in viewport pixels (0,0 = top-left)',
|
||
},
|
||
y: {
|
||
type: 'number',
|
||
description: '[mouse_button, mouse_motion] Y position in viewport pixels (0,0 = top-left)',
|
||
},
|
||
relative_x: {
|
||
type: 'number',
|
||
description: '[mouse_motion] Relative X movement in pixels',
|
||
},
|
||
relative_y: {
|
||
type: 'number',
|
||
description: '[mouse_motion] Relative Y movement in pixels',
|
||
},
|
||
double_click: {
|
||
type: 'boolean',
|
||
description: '[mouse_button, click_element] Double click',
|
||
},
|
||
element: {
|
||
type: 'string',
|
||
description: '[click_element] Identifies the UI element to click. Accepts: absolute node path (e.g. "/root/HUD/Button"), relative node path, or node name (BFS matched). Use get_ui_elements to discover valid names and paths.',
|
||
},
|
||
action: {
|
||
type: 'string',
|
||
description: '[action] Godot input action name (as defined in Project Settings > Input Map)',
|
||
},
|
||
strength: {
|
||
type: 'number',
|
||
description: '[action] Action strength (0–1, default 1.0)',
|
||
},
|
||
ms: {
|
||
type: 'number',
|
||
description: '[wait] Duration in milliseconds to pause before the next action (~16ms = one frame at 60fps).',
|
||
},
|
||
},
|
||
required: ['type'],
|
||
},
|
||
},
|
||
},
|
||
required: ['actions'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
success: { type: 'boolean' },
|
||
actions_processed: { type: 'number' },
|
||
warnings: { type: 'array', items: { type: 'string' } },
|
||
tip: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'get_ui_elements',
|
||
description: 'Walk the running scene tree and return all Control nodes with positions, sizes, types, and text content. Always call this before simulate_input click_element actions to discover valid element names and paths. Requires an active runtime session (run_project or attach_project). visibleOnly defaults true; pass false to include hidden Controls. filter narrows by class. Returns: elements[] with path/type/rect/visible plus optional text/disabled/tooltip.',
|
||
annotations: { readOnlyHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
visibleOnly: {
|
||
type: 'boolean',
|
||
description: 'Only return nodes where Control.visible is true (default: true). Set false to include hidden elements.',
|
||
},
|
||
filter: {
|
||
type: 'string',
|
||
description: 'Filter by Control node type (e.g. "Button", "Label", "LineEdit")',
|
||
},
|
||
},
|
||
required: [],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
elements: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string' },
|
||
path: { type: 'string' },
|
||
type: { type: 'string' },
|
||
rect: {
|
||
type: 'object',
|
||
properties: {
|
||
x: { type: 'number' },
|
||
y: { type: 'number' },
|
||
width: { type: 'number' },
|
||
height: { type: 'number' },
|
||
},
|
||
},
|
||
visible: { type: 'boolean' },
|
||
text: { type: 'string' },
|
||
placeholder: { type: 'string' },
|
||
disabled: { type: 'boolean' },
|
||
tooltip: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
warnings: { type: 'array', items: { type: 'string' } },
|
||
tip: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: 'run_script',
|
||
description: 'Execute a custom GDScript in the live running project with full scene tree access. Requires an active runtime session. Script must extend RefCounted and define func execute(scene_tree: SceneTree) -> Variant. Return values are JSON-serialized (primitives, Vector2/3, Color, Dictionary, Array, and Node path strings). Use print() for debug output — it appears in get_debug_output, not in the result. In spawned mode, stderr runtime errors escalate to errors (when the script returns null) or surface as warnings. Returns: { success, result, warnings?, tip? } where result is the JSON-serialized return value of execute().',
|
||
annotations: { destructiveHint: true },
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
script: {
|
||
type: 'string',
|
||
description: 'GDScript source code. Must contain "extends RefCounted" and "func execute(scene_tree: SceneTree) -> Variant".',
|
||
},
|
||
timeout: {
|
||
type: 'number',
|
||
description: 'Timeout in ms (default: 30000). Increase for long-running scripts.',
|
||
},
|
||
},
|
||
required: ['script'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
success: { type: 'boolean' },
|
||
result: {},
|
||
warnings: { type: 'array', items: { type: 'string' } },
|
||
tip: { type: 'string' },
|
||
},
|
||
},
|
||
},
|
||
];
|
||
// --- Helpers ---
|
||
function ensureRuntimeSession(runner, actionDescription) {
|
||
if (!runner.activeSessionMode || !runner.activeProjectPath) {
|
||
return createErrorResponse(`No active runtime session. A project must be running or attached to ${actionDescription}.`, [
|
||
'Use run_project to start a Godot project first',
|
||
'Or use attach_project before launching Godot manually',
|
||
]);
|
||
}
|
||
if (runner.activeSessionMode === 'spawned' &&
|
||
(!runner.activeProcess || runner.activeProcess.hasExited)) {
|
||
return createErrorResponse(`The spawned Godot process has exited and cannot ${actionDescription}.`, [
|
||
'Use get_debug_output to inspect the last captured logs',
|
||
'Call stop_project to clean up, then run_project again',
|
||
]);
|
||
}
|
||
return null;
|
||
}
|
||
// --- Handlers ---
|
||
export async function handleLaunchEditor(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const v = validateProjectArgs(args);
|
||
if ('isError' in v)
|
||
return v;
|
||
try {
|
||
if (!runner.getGodotPath()) {
|
||
await runner.detectGodotPath();
|
||
if (!runner.getGodotPath()) {
|
||
return createErrorResponse('Could not find a valid Godot executable path', [
|
||
'Ensure Godot is installed correctly',
|
||
'Set GODOT_PATH environment variable',
|
||
]);
|
||
}
|
||
}
|
||
logDebug(`Launching Godot editor for project: ${v.projectPath}`);
|
||
const process = runner.launchEditor(v.projectPath);
|
||
process.on('error', (err) => {
|
||
console.error('Failed to start Godot editor:', err);
|
||
});
|
||
return {
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `Godot editor launched successfully for project at ${v.projectPath}.\nNote: the editor is a GUI application and cannot be controlled programmatically. Use the scene and node editing tools (add_node, set_node_properties, etc.) to modify the project headlessly without the editor.`,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
catch (error) {
|
||
return createErrorResponse(`Failed to launch Godot editor: ${getErrorMessage(error)}`, [
|
||
'Ensure Godot is installed correctly',
|
||
'Check if the GODOT_PATH environment variable is set correctly',
|
||
]);
|
||
}
|
||
}
|
||
export async function handleRunProject(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const v = validateProjectArgs(args);
|
||
if ('isError' in v)
|
||
return v;
|
||
if (typeof args.scene === 'string') {
|
||
if (!validateSubPath(v.projectPath, args.scene)) {
|
||
return createErrorResponse(`Invalid scene path: must be project-relative without ".." (got: ${args.scene})`, ['Pass scene as a path relative to the project root, e.g. "scenes/main.tscn"']);
|
||
}
|
||
}
|
||
if (!runner.getGodotPath()) {
|
||
await runner.detectGodotPath();
|
||
if (!runner.getGodotPath()) {
|
||
return createErrorResponse('Could not find a valid Godot executable path', [
|
||
'Set GODOT_PATH in your MCP client config to your Godot 4.x executable',
|
||
'Ensure the path points at the Godot binary, not its installation folder',
|
||
'On Windows, escape backslashes in JSON (e.g. "D:\\\\Godot\\\\Godot.exe")',
|
||
]);
|
||
}
|
||
}
|
||
try {
|
||
const background = args.background === true;
|
||
const bridgePort = args.bridgePort;
|
||
if (bridgePort !== undefined) {
|
||
if (!Number.isInteger(bridgePort) ||
|
||
bridgePort < 1 ||
|
||
bridgePort > 65535) {
|
||
return createErrorResponse(`Invalid bridgePort: must be an integer in [1, 65535] (got: ${String(bridgePort)})`, ['Omit bridgePort to auto-select a free port', 'Pass a valid TCP port number']);
|
||
}
|
||
}
|
||
await runner.runProject(v.projectPath, args.scene, background, bridgePort);
|
||
const bridgeResult = await runner.waitForBridge();
|
||
if (!bridgeResult.ready) {
|
||
if (runner.activeProcess && runner.activeProcess.hasExited) {
|
||
// Tear down the spawned-mode session state so a retry of run_project
|
||
// works without an intervening stop_project.
|
||
await runner.stopProject();
|
||
return createErrorResponse(`Godot process exited before the MCP bridge could initialize.\n${bridgeResult.error || ''}`, [
|
||
'Check get_debug_output for runtime errors',
|
||
'Verify a display server is available (Wayland/X11)',
|
||
'Check for broken autoloads with list_autoloads',
|
||
'Retry run_project once the underlying issue is resolved',
|
||
]);
|
||
}
|
||
const recentErrors = runner.getRecentErrors(20);
|
||
const errorTail = recentErrors.length > 0 ? `\nLast stderr:\n${recentErrors.join('\n')}` : '';
|
||
const expected = runner.activeBridgePort;
|
||
const onDisk = runner.readBakedBridgePort(v.projectPath);
|
||
const raceDetected = onDisk !== null && expected !== null && onDisk !== expected;
|
||
const racePrefix = raceDetected
|
||
? `Bridge timeout: expected port ${expected}, but on-disk script now has ${onDisk}. Another MCP client likely re-injected concurrently in the same project.\n`
|
||
: '';
|
||
const lines = [
|
||
`${racePrefix}Godot process started, but the MCP bridge did not respond within ${BRIDGE_WAIT_SPAWNED_TIMEOUT_MS / 1000} seconds.`,
|
||
'- The bridge listener never came up — likely an early _ready error or a stuck process holding the port',
|
||
'- Session has been torn down; retry run_project to start a new one',
|
||
errorTail,
|
||
];
|
||
if (background) {
|
||
lines.push('- Background mode: window hidden, physical input blocked');
|
||
}
|
||
// Tear down before returning so hasActiveRuntimeSession() reports false
|
||
// and the next run_project lazy-reconnects cleanly.
|
||
await runner.stopProject();
|
||
const solutions = [
|
||
'Check for broken autoloads with list_autoloads',
|
||
`Check that the assigned bridge port (${runner.activeBridgePort}) is not occupied by another Godot process`,
|
||
'Retry run_project',
|
||
];
|
||
if (raceDetected) {
|
||
solutions.push('Concurrent MCP clients in the same project are not supported — run them in separate projects or sequence the calls');
|
||
}
|
||
return createErrorResponse(lines.join('\n'), solutions);
|
||
}
|
||
const port = runner.activeBridgePort;
|
||
const lines = [
|
||
`Godot project started and MCP bridge is ready (port ${port}).`,
|
||
'- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
|
||
'- Use get_debug_output to check runtime output and errors',
|
||
'- Call stop_project when done',
|
||
];
|
||
if (background) {
|
||
lines.push('- Background mode: window hidden, physical input blocked');
|
||
}
|
||
return {
|
||
content: [{ type: 'text', text: lines.join('\n') }],
|
||
};
|
||
}
|
||
catch (error) {
|
||
const errorMessage = getErrorMessage(error);
|
||
if (errorMessage.includes('No display server available')) {
|
||
return createErrorResponse(`Failed to run Godot project: ${errorMessage}`, [
|
||
'Use attach_project with an externally launched Godot process',
|
||
'Set DISPLAY or WAYLAND_DISPLAY environment variables',
|
||
'Run from a graphical shell session',
|
||
]);
|
||
}
|
||
return createErrorResponse(`Failed to run Godot project: ${errorMessage}`, [
|
||
'Ensure Godot is installed correctly',
|
||
'Check if the GODOT_PATH environment variable is set correctly',
|
||
]);
|
||
}
|
||
}
|
||
export async function handleAttachProject(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const v = validateProjectArgs(args);
|
||
if ('isError' in v)
|
||
return v;
|
||
try {
|
||
const attachBridgePort = args.bridgePort;
|
||
if (attachBridgePort !== undefined) {
|
||
if (!Number.isInteger(attachBridgePort) ||
|
||
attachBridgePort < 1 ||
|
||
attachBridgePort > 65535) {
|
||
return createErrorResponse(`Invalid bridgePort: must be an integer in [1, 65535] (got: ${String(attachBridgePort)})`, [
|
||
'Omit bridgePort to auto-select a free port',
|
||
'Pass a valid TCP port number matching the externally launched Godot',
|
||
]);
|
||
}
|
||
}
|
||
await runner.attachProject(v.projectPath, attachBridgePort);
|
||
const bridgeResult = await runner.waitForBridgeAttached();
|
||
if (!bridgeResult.ready) {
|
||
const expected = runner.activeBridgePort;
|
||
const onDisk = runner.readBakedBridgePort(v.projectPath);
|
||
const raceDetected = onDisk !== null && expected !== null && onDisk !== expected;
|
||
const racePrefix = raceDetected
|
||
? `Bridge timeout: expected port ${expected}, but on-disk script now has ${onDisk}. Another MCP client likely re-injected concurrently in the same project.\n`
|
||
: '';
|
||
// Tear down the attached-mode session state so retrying with
|
||
// attach_project (or run_project) works without a manual detach first.
|
||
await runner.stopProject();
|
||
const solutions = [
|
||
'If you are launching Godot yourself, run the launch in parallel with attach_project next time so the wait absorbs the startup — do not sequentialize',
|
||
'If a human is launching Godot, retry attach_project once they have launched — bridge.inject is idempotent',
|
||
'If Godot is already running but was launched before the bridge was injected, restart it (autoloads are read at startup)',
|
||
`Check that no other Godot project is occupying the assigned bridge port (${runner.activeBridgePort})`,
|
||
];
|
||
if (raceDetected) {
|
||
solutions.push('Concurrent MCP clients in the same project are not supported — run them in separate projects or sequence the calls');
|
||
}
|
||
return createErrorResponse(`${racePrefix}Project attached but the MCP bridge is not ready.\n${bridgeResult.error || ''}`, solutions);
|
||
}
|
||
const attachedPort = runner.activeBridgePort;
|
||
return {
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: [
|
||
`Project attached and MCP bridge is ready (port ${attachedPort}).`,
|
||
'- Runtime tools (take_screenshot, simulate_input, get_ui_elements, run_script) are available now',
|
||
'- get_debug_output is unavailable in attached mode because MCP did not spawn the process',
|
||
'- Use detach_project or stop_project when done to clean up the injected bridge state',
|
||
].join('\n'),
|
||
},
|
||
],
|
||
};
|
||
}
|
||
catch (error) {
|
||
return createErrorResponse(`Failed to attach project: ${getErrorMessage(error)}`, [
|
||
'Check if project.godot is accessible',
|
||
'Ensure MCP can write the bridge autoload into the project',
|
||
]);
|
||
}
|
||
}
|
||
export async function handleDetachProject(runner) {
|
||
if (runner.activeSessionMode !== 'attached') {
|
||
return createErrorResponse('No attached project to detach.', [
|
||
'Use attach_project first for manual-launch workflows',
|
||
'If MCP launched the game, use stop_project instead',
|
||
]);
|
||
}
|
||
const result = (await runner.stopProject());
|
||
return createStructuredResponse({
|
||
message: 'Detached attached project and cleaned MCP bridge state',
|
||
externalProcessPreserved: result.externalProcessPreserved === true,
|
||
});
|
||
}
|
||
export function handleGetDebugOutput(runner, args = {}) {
|
||
args = normalizeParameters(args);
|
||
if (!runner.activeSessionMode) {
|
||
return createErrorResponse('No active runtime session.', [
|
||
'Use run_project to start a Godot project first',
|
||
'Or use attach_project before launching Godot manually',
|
||
]);
|
||
}
|
||
if (runner.activeSessionMode === 'attached') {
|
||
return createStructuredResponse({
|
||
output: [],
|
||
errors: [],
|
||
running: null,
|
||
attached: true,
|
||
tip: 'Attached mode does not capture stdout/stderr because Godot was launched outside MCP.',
|
||
});
|
||
}
|
||
const proc = runner.activeProcess;
|
||
if (!proc) {
|
||
return createErrorResponse('No active spawned process is available for debug output.', [
|
||
'Use run_project to start a Godot project first',
|
||
'Or use attach_project only when stdout/stderr capture is not needed',
|
||
]);
|
||
}
|
||
const limit = typeof args.limit === 'number' ? args.limit : 200;
|
||
const response = {
|
||
output: proc.output.slice(-limit),
|
||
errors: proc.errors.slice(-limit),
|
||
running: !proc.hasExited,
|
||
};
|
||
if (proc.hasExited) {
|
||
response.exitCode = proc.exitCode;
|
||
response.tip =
|
||
'Process has exited. Call stop_project to clean up the process slot before starting a new one.';
|
||
}
|
||
return createStructuredResponse(response);
|
||
}
|
||
export async function handleStopProject(runner) {
|
||
const result = await runner.stopProject();
|
||
if (!result) {
|
||
return createErrorResponse('No active Godot process to stop.', [
|
||
'Use run_project to start a Godot project first',
|
||
'The process may have already terminated',
|
||
]);
|
||
}
|
||
return createStructuredResponse({
|
||
message: result.mode === 'attached'
|
||
? 'Attached project detached and MCP bridge state cleaned up'
|
||
: 'Godot project stopped',
|
||
mode: result.mode,
|
||
externalProcessPreserved: result.externalProcessPreserved === true,
|
||
finalOutput: result.output.slice(-200),
|
||
finalErrors: result.errors.slice(-200),
|
||
});
|
||
}
|
||
function parseScreenshotResponseMode(value) {
|
||
if (value === undefined)
|
||
return 'preview';
|
||
if (typeof value !== 'string')
|
||
return null;
|
||
return SCREENSHOT_RESPONSE_MODES.includes(value)
|
||
? value
|
||
: null;
|
||
}
|
||
function parsePreviewDimension(value, fallback) {
|
||
if (value === undefined)
|
||
return fallback;
|
||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
||
return null;
|
||
return Math.max(1, Math.floor(value));
|
||
}
|
||
function normalizeScreenshotPath(path) {
|
||
return sep === '\\' ? path.replace(/\//g, '\\') : path;
|
||
}
|
||
export async function handleTakeScreenshot(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const sessionError = ensureRuntimeSession(runner, 'take a screenshot');
|
||
if (sessionError) {
|
||
return sessionError;
|
||
}
|
||
const timeout = typeof args.timeout === 'number' ? args.timeout : 10000;
|
||
const responseMode = parseScreenshotResponseMode(args.responseMode);
|
||
if (responseMode === null) {
|
||
return createErrorResponse('Invalid responseMode for take_screenshot', [
|
||
'Use one of: "full", "preview", or "path_only"',
|
||
]);
|
||
}
|
||
const previewMaxWidth = parsePreviewDimension(args.previewMaxWidth, DEFAULT_PREVIEW_MAX_WIDTH);
|
||
const previewMaxHeight = parsePreviewDimension(args.previewMaxHeight, DEFAULT_PREVIEW_MAX_HEIGHT);
|
||
if (previewMaxWidth === null || previewMaxHeight === null) {
|
||
return createErrorResponse('Invalid preview dimensions for take_screenshot', [
|
||
'previewMaxWidth and previewMaxHeight must be positive numbers',
|
||
]);
|
||
}
|
||
const commandParams = {};
|
||
if (responseMode === 'preview') {
|
||
commandParams.preview_max_width = previewMaxWidth;
|
||
commandParams.preview_max_height = previewMaxHeight;
|
||
}
|
||
try {
|
||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('screenshot', commandParams, timeout);
|
||
const parsedResult = parseBridgeJson(responseStr, 'screenshot');
|
||
if (!parsedResult.ok)
|
||
return parsedResult.response;
|
||
const parsed = parsedResult.data;
|
||
if (parsed.error) {
|
||
return createErrorResponse(`Screenshot server error: ${parsed.error}`, [
|
||
'Ensure the project has a viewport (a headless project with no display server cannot render)',
|
||
'Check disk space and permissions on the project directory (.mcp/screenshots/)',
|
||
]);
|
||
}
|
||
if (!parsed.path) {
|
||
return createErrorResponse('Screenshot server returned no file path', [
|
||
'The bridge response is missing the expected `path` field — this is a bridge bug, not a timing issue',
|
||
'Check get_debug_output for runtime errors during the screenshot save',
|
||
]);
|
||
}
|
||
// Normalize path for the local filesystem (forward slashes from GDScript)
|
||
const screenshotPath = normalizeScreenshotPath(parsed.path);
|
||
// Defense-in-depth: the bridge runs in user-controlled GDScript and could
|
||
// be patched to return any path. Refuse to read anything outside the
|
||
// project's own .mcp/screenshots/ directory.
|
||
const screenshotsRoot = resolve(runner.activeProjectPath, '.mcp', 'screenshots');
|
||
if (!isUnderDir(screenshotsRoot, screenshotPath)) {
|
||
return createErrorResponse('Bridge returned a screenshot path outside .mcp/screenshots/. Refusing to read.', [
|
||
'This indicates a tampered or misbehaving McpBridge autoload',
|
||
'Stop the project, verify the bridge script is the one shipped with this server, and retry',
|
||
]);
|
||
}
|
||
if (!existsSync(screenshotPath)) {
|
||
return createErrorResponse(`Screenshot file not found at: ${screenshotPath}`, [
|
||
'The screenshot may have failed to save',
|
||
'Check disk space and permissions',
|
||
]);
|
||
}
|
||
const metadata = {
|
||
responseMode,
|
||
path: parsed.path,
|
||
size: { width: parsed.width, height: parsed.height },
|
||
};
|
||
const content = [];
|
||
if (responseMode === 'full') {
|
||
const imageBuffer = readFileSync(screenshotPath);
|
||
content.push({
|
||
type: 'image',
|
||
data: imageBuffer.toString('base64'),
|
||
mimeType: 'image/png',
|
||
});
|
||
}
|
||
else if (responseMode === 'preview') {
|
||
if (!parsed.preview_path) {
|
||
return createErrorResponse('Screenshot server returned no preview path', [
|
||
'Ensure the running project has the current McpBridge autoload',
|
||
'Restart the runtime after rebuilding the MCP server',
|
||
]);
|
||
}
|
||
const previewPath = normalizeScreenshotPath(parsed.preview_path);
|
||
if (!isUnderDir(screenshotsRoot, previewPath)) {
|
||
return createErrorResponse('Bridge returned a screenshot preview path outside .mcp/screenshots/. Refusing to read.', [
|
||
'This indicates a tampered or misbehaving McpBridge autoload',
|
||
'Stop the project, verify the bridge script is the one shipped with this server, and retry',
|
||
]);
|
||
}
|
||
if (!existsSync(previewPath)) {
|
||
return createErrorResponse(`Screenshot preview file not found at: ${previewPath}`, [
|
||
'The preview may have failed to save',
|
||
'Try again, or use responseMode "full" to return the original screenshot',
|
||
]);
|
||
}
|
||
const previewBuffer = readFileSync(previewPath);
|
||
content.push({
|
||
type: 'image',
|
||
data: previewBuffer.toString('base64'),
|
||
mimeType: 'image/png',
|
||
});
|
||
metadata.previewPath = parsed.preview_path;
|
||
metadata.previewSize = { width: parsed.preview_width, height: parsed.preview_height };
|
||
}
|
||
attachRuntimeWarnings(metadata, runtimeErrors);
|
||
return createStructuredResponse(metadata, content);
|
||
}
|
||
catch (error) {
|
||
return createErrorResponse(`Failed to take screenshot: ${getErrorMessage(error)}`, [
|
||
'Check get_debug_output for crash backtraces or runtime errors',
|
||
'If the game has exited, call stop_project, then run_project again',
|
||
'For slow renders, increase the timeout parameter',
|
||
]);
|
||
}
|
||
}
|
||
export async function handleSimulateInput(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const sessionError = ensureRuntimeSession(runner, 'simulate input');
|
||
if (sessionError) {
|
||
return sessionError;
|
||
}
|
||
const actions = args.actions;
|
||
if (!Array.isArray(actions) || actions.length === 0) {
|
||
return createErrorResponse('actions must be a non-empty array of input actions', [
|
||
'Provide at least one action object with a "type" field',
|
||
]);
|
||
}
|
||
// Calculate timeout: sum of all wait durations + 10s buffer
|
||
let totalWaitMs = 0;
|
||
for (const action of actions) {
|
||
if (typeof action === 'object' &&
|
||
action !== null &&
|
||
action.type === 'wait' &&
|
||
typeof action.ms === 'number') {
|
||
totalWaitMs += action.ms;
|
||
}
|
||
}
|
||
const timeoutMs = totalWaitMs + 10000;
|
||
try {
|
||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('input', { actions }, timeoutMs);
|
||
const parsedResult = parseBridgeJson(responseStr, 'simulate_input');
|
||
if (!parsedResult.ok)
|
||
return parsedResult.response;
|
||
const parsed = parsedResult.data;
|
||
if (parsed.error) {
|
||
return createErrorResponse(`Input simulation error: ${parsed.error}`, [
|
||
'Check action types and parameters',
|
||
'Ensure key names are valid Godot key names',
|
||
]);
|
||
}
|
||
const payload = {
|
||
success: true,
|
||
actions_processed: parsed.actions_processed,
|
||
tip: 'Call take_screenshot to verify the input had the intended visual effect.',
|
||
};
|
||
attachRuntimeWarnings(payload, runtimeErrors);
|
||
return createStructuredResponse(payload);
|
||
}
|
||
catch (error) {
|
||
return createErrorResponse(`Failed to simulate input: ${getErrorMessage(error)}`, [
|
||
'Check get_debug_output for crash backtraces or runtime errors (a signal handler firing on input may have crashed the game)',
|
||
'If the game has exited, call stop_project, then run_project again',
|
||
]);
|
||
}
|
||
}
|
||
export async function handleGetUiElements(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const sessionError = ensureRuntimeSession(runner, 'query UI elements');
|
||
if (sessionError) {
|
||
return sessionError;
|
||
}
|
||
const visibleOnly = args.visibleOnly !== false;
|
||
try {
|
||
const cmdParams = { visible_only: visibleOnly };
|
||
if (args.filter)
|
||
cmdParams.type_filter = args.filter;
|
||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('get_ui_elements', cmdParams);
|
||
const parsedResult = parseBridgeJson(responseStr, 'get_ui_elements');
|
||
if (!parsedResult.ok)
|
||
return parsedResult.response;
|
||
const parsed = parsedResult.data;
|
||
if (parsed.error) {
|
||
return createErrorResponse(`UI element query error: ${parsed.error}`, [
|
||
'Ensure the game has a UI with Control nodes',
|
||
]);
|
||
}
|
||
const payload = {
|
||
...parsed,
|
||
tip: "Use simulate_input with type 'click_element' and a node_path or node name from this list to interact with these elements.",
|
||
};
|
||
attachRuntimeWarnings(payload, runtimeErrors);
|
||
return createStructuredResponse(payload);
|
||
}
|
||
catch (error) {
|
||
return createErrorResponse(`Failed to get UI elements: ${getErrorMessage(error)}`, [
|
||
'Check get_debug_output for crash backtraces or runtime errors',
|
||
'If the game has exited, call stop_project, then run_project again',
|
||
]);
|
||
}
|
||
}
|
||
export async function handleRunScript(runner, args) {
|
||
args = normalizeParameters(args);
|
||
const sessionError = ensureRuntimeSession(runner, 'execute scripts');
|
||
if (sessionError) {
|
||
return sessionError;
|
||
}
|
||
const script = args.script;
|
||
if (typeof script !== 'string' || script.trim() === '') {
|
||
return createErrorResponse('script is required and must be a non-empty string', [
|
||
'Provide GDScript source code with extends RefCounted and func execute(scene_tree: SceneTree) -> Variant',
|
||
]);
|
||
}
|
||
if (!script.includes('func execute')) {
|
||
return createErrorResponse('Script must define func execute(scene_tree: SceneTree) -> Variant', ['Add a func execute(scene_tree: SceneTree) -> Variant method to your script']);
|
||
}
|
||
// Write script to .mcp/scripts/ for audit trail
|
||
try {
|
||
const projectPath = runner.activeProjectPath;
|
||
if (projectPath) {
|
||
const scriptsDir = join(projectPath, '.mcp', 'scripts');
|
||
mkdirSync(scriptsDir, { recursive: true });
|
||
const timestamp = Date.now();
|
||
const scriptFile = join(scriptsDir, `${timestamp}-${randomUUID()}.gd`);
|
||
writeFileSync(scriptFile, script, 'utf8');
|
||
logDebug(`Saved script to ${scriptFile}`);
|
||
}
|
||
}
|
||
catch (error) {
|
||
logDebug(`Failed to save script for audit: ${error}`);
|
||
}
|
||
const timeout = typeof args.timeout === 'number' ? args.timeout : 30000;
|
||
try {
|
||
const { response: responseStr, runtimeErrors } = await runner.sendCommandWithErrors('run_script', { source: script }, timeout);
|
||
const parsedResult = parseBridgeJson(responseStr, 'run_script');
|
||
if (!parsedResult.ok)
|
||
return parsedResult.response;
|
||
const parsed = parsedResult.data;
|
||
if (parsed.error) {
|
||
return createErrorResponse(`Script execution error: ${parsed.error}`, [
|
||
'Check your GDScript syntax',
|
||
'Ensure the script extends RefCounted',
|
||
'Check get_debug_output for details',
|
||
]);
|
||
}
|
||
// Detect false-positive success: GDScript has no try-catch, so runtime errors
|
||
// return null and the real error only appears in stderr.
|
||
if (parsed.success && parsed.result === null && runner.activeSessionMode === 'spawned') {
|
||
if (runtimeErrors.length > 0) {
|
||
const errorContext = runtimeErrors.slice(0, MAX_RUNTIME_ERROR_CONTEXT_LINES).join('\n');
|
||
return createErrorResponse(`Script runtime error detected:\n${errorContext}`, [
|
||
'Fix the GDScript error in your script and retry',
|
||
'Use get_debug_output for full process output',
|
||
]);
|
||
}
|
||
return createStructuredResponse({
|
||
success: true,
|
||
result: null,
|
||
warnings: [
|
||
'Script returned null. If unexpected, check get_debug_output for runtime errors — GDScript does not propagate exceptions.',
|
||
],
|
||
tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
|
||
});
|
||
}
|
||
const payload = {
|
||
success: true,
|
||
result: parsed.result,
|
||
tip: 'Call take_screenshot to verify any visual changes, or get_debug_output to review print() output from your script.',
|
||
};
|
||
attachRuntimeWarnings(payload, runtimeErrors);
|
||
return createStructuredResponse(payload);
|
||
}
|
||
catch (error) {
|
||
return createErrorResponse(`Failed to execute script: ${getErrorMessage(error)}`, [
|
||
'Check get_debug_output for crash backtraces or runtime errors raised inside the script',
|
||
'If the game has exited, call stop_project, then run_project again',
|
||
'For long-running scripts, increase the timeout parameter',
|
||
]);
|
||
}
|
||
}
|
||
//# sourceMappingURL=runtime-tools.js.map
|