import { z } from "zod"; import { formatErrorForMcp } from "../utils/errors.js"; import { coerceStringArray, coerceNumber } from "../utils/zod-coerce.js"; export function registerRuntimeTools(server, godot) { server.tool("get_game_scene_tree", "Get the scene tree of the currently running game (requires a scene to be playing). Supports filtering by script path, node type, or name.", { max_depth: z .number() .optional() .describe("Maximum tree depth (-1 for unlimited, default: -1)"), script_filter: z .string() .optional() .describe("Only include nodes whose script path contains this string (e.g. 'enemy' matches 'enemy.gd', 'enemy_drone.gd')"), type_filter: z .string() .optional() .describe("Only include nodes of this Godot class (e.g. 'CharacterBody2D', 'Area2D')"), named_only: z .boolean() .optional() .describe("If true, exclude nodes with auto-generated names (names starting with '@'). Default: false"), }, async (params) => { try { const result = await godot.sendCommand("get_game_scene_tree", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("get_game_node_properties", "Get properties of a node in the running game (requires a scene to be playing)", { node_path: z .string() .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), properties: coerceStringArray() .optional() .describe("Specific property names to read (default: all editor-visible properties)"), }, async (params) => { try { const result = await godot.sendCommand("get_game_node_properties", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("set_game_node_property", "Set a property on a node in the running game (requires a scene to be playing). Useful for live-tweaking values like position, speed, health, etc.", { node_path: z .string() .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), property: z .string() .describe("Property name to set (e.g. 'position', 'speed', 'health')"), value: z .union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.number())]) .describe("Value to set. Accepts: strings with auto-parsing ('Vector2(100,200)', '#ff0000'), numbers, booleans, or JSON objects for vectors/colors ({\"x\":5,\"y\":3,\"z\":10} for Vector3, {\"x\":100,\"y\":200} for Vector2, {\"r\":1,\"g\":0,\"b\":0,\"a\":1} for Color)"), }, async (params) => { try { const result = await godot.sendCommand("set_game_node_property", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("execute_game_script", "Execute arbitrary GDScript code inside the running game process. Use _mcp_print() to output values. Has access to the live scene tree and all game nodes.", { code: z .string() .describe("GDScript code to execute in the running game. Use _mcp_print(value) to capture output. Code runs inside a run() function with access to the live game scene tree."), }, async (params) => { try { const result = await godot.sendCommand("execute_game_script", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("capture_frames", "Capture multiple screenshots at regular frame intervals from the running game. Returns base64 PNG images. Use this to verify animations are playing correctly — if character poses differ across frames, the animation is working; if all frames show the same pose (e.g. T-pose), animation loading failed. Also useful for verifying movement, physics, and any time-based behavior. Prefer this over get_game_screenshot when you need to confirm something is changing over time.", { count: coerceNumber() .optional() .describe("Number of frames to capture (1-30, default: 5)"), frame_interval: coerceNumber() .optional() .describe("Frames to wait between captures (default: 10, i.e. ~6 captures/sec at 60fps)"), half_resolution: z .boolean() .optional() .describe("Halve resolution to reduce data size (default: true)"), node_data: z .object({ node_path: z.string().describe("Path to a node to track (e.g. '/root/Main/Player')"), properties: coerceStringArray().describe("Property names to capture per frame (e.g. ['global_position', 'velocity'])"), }) .optional() .describe("Optional: capture node property data alongside each frame for debugging (position, velocity, etc.)"), }, async (params) => { try { const result = (await godot.sendCommand("capture_frames", params)); if (result && typeof result === "object" && "frames" in result && Array.isArray(result.frames)) { const content = []; const frameData = Array.isArray(result.frame_data) ? result.frame_data : null; for (let i = 0; i < result.frames.length; i++) { content.push({ type: "image", data: result.frames[i], mimeType: "image/png", }); if (frameData && frameData[i]) { content.push({ type: "text", text: `Frame ${i + 1}: ${JSON.stringify(frameData[i])}`, }); } } content.push({ type: "text", text: `Captured ${result.count} frames (${result.width}x${result.height}${result.half_resolution ? ", half-res" : ""})`, }); return { content }; } return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("record_frames", "Record many screenshots to files on disk for long-running debug observation. Unlike capture_frames (which returns base64 images directly), this saves PNG files to user://mcp_recorded_frames/ and returns file paths. Use this when you need more than 30 frames or want to observe behavior over a longer period without flooding the context with image data.", { count: coerceNumber() .optional() .describe("Number of frames to capture (1-600, default: 30)"), frame_interval: coerceNumber() .optional() .describe("Frames to wait between captures (default: 10, i.e. ~6 captures/sec at 60fps)"), half_resolution: z .boolean() .optional() .describe("Halve resolution to reduce file size (default: true)"), node_data: z .object({ node_path: z.string().describe("Path to a node to track (e.g. '/root/Main/Player')"), properties: coerceStringArray().describe("Property names to capture per frame (e.g. ['global_position', 'velocity'])"), }) .optional() .describe("Optional: capture node property data alongside each frame for debugging"), }, async (params) => { try { const result = (await godot.sendCommand("record_frames", params)); const content = []; content.push({ type: "text", text: `Recorded ${result.count} frames to ${result.directory}/ (${result.width}x${result.height}${result.half_resolution ? ", half-res" : ""})`, }); if (Array.isArray(result.files)) { content.push({ type: "text", text: `Files:\n${result.files.join("\n")}`, }); } if (Array.isArray(result.frame_data) && result.frame_data.length > 0) { content.push({ type: "text", text: `Node data:\n${JSON.stringify(result.frame_data, null, 2)}`, }); } return { content }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("monitor_properties", "Record property values over multiple frames from the running game. Returns a timeline of samples. Great for verifying movement (position changing), animation state (current_animation property), physics behavior (velocity), and debugging time-dependent issues.", { node_path: z .string() .describe("Absolute node path in the running game (e.g. '/root/Main/Player')"), properties: coerceStringArray() .describe("Property names to monitor (e.g. ['position', 'velocity'])"), frame_count: coerceNumber() .optional() .describe("Number of samples to collect (1-600, default: 60)"), frame_interval: coerceNumber() .optional() .describe("Frames to wait between samples (default: 1, every frame)"), }, async (params) => { try { const result = await godot.sendCommand("monitor_properties", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("watch_signals", "Monitor signal emissions on specified nodes in the running game for a duration. Returns a timestamped log of every signal fired — great for debugging event flow, verifying signal connections, and understanding runtime behavior.", { node_paths: z .array(z.string()) .describe("Absolute node paths to watch (e.g. ['/root/Main/Player', '/root/Main/Enemy'])"), signal_filter: z .array(z.string()) .optional() .describe("Only watch signals containing these substrings (e.g. ['health', 'died']). Omit to watch all signals."), duration_ms: coerceNumber() .optional() .describe("How long to watch in milliseconds (500-30000, default: 5000)"), }, async (params) => { try { const result = await godot.sendCommand("watch_signals", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("start_recording", "Start recording all input events (keyboard, mouse, actions) in the running game. Use stop_recording to get the recorded events.", {}, async () => { try { const result = await godot.sendCommand("start_recording"); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; } }); server.tool("stop_recording", "Stop recording input events and return the recorded event timeline. Events include timestamps for replay.", {}, async () => { try { const result = await godot.sendCommand("stop_recording"); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; } }); server.tool("replay_recording", "Replay a previously recorded input event sequence in the running game. Useful for regression testing — record a test once, replay it after code changes.", { events: z.array(z.record(z.string(), z.unknown())).describe("Array of recorded event objects (from stop_recording output)"), speed: z.number().optional().describe("Playback speed multiplier (default: 1.0, 2.0 = double speed)"), }, async (params) => { try { const result = await godot.sendCommand("replay_recording", params); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true }; } }); server.tool("find_nodes_by_script", "Find all nodes in the running game whose script path contains a given string. Returns matching nodes with their properties.", { script: z .string() .describe("Script path substring to search for (e.g. 'enemy', 'player.gd')"), properties: coerceStringArray() .optional() .describe("Specific property names to include for each match (default: all editor-visible properties)"), }, async (params) => { try { const result = await godot.sendCommand("find_nodes_by_script", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("get_autoload", "Get properties of an autoload/singleton node in the running game. Quick access to global game state like GameManager, EventBus, etc.", { name: z .string() .describe("Autoload name (e.g. 'GameManager', 'EventBus', 'SaveManager')"), properties: coerceStringArray() .optional() .describe("Specific property names to read (default: all editor-visible properties)"), }, async (params) => { try { const result = await godot.sendCommand("get_autoload", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("find_ui_elements", "Find all visible UI elements (Button, Label, LineEdit, CheckBox, Slider, etc.) in the running game. Returns each element's text, type, position, and center point for clicking.", { type_filter: z .string() .optional() .describe("Only return elements of this type (e.g. 'Button', 'Label', 'CheckBox'). Default: all types"), }, async (params) => { try { const result = await godot.sendCommand("find_ui_elements", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("click_button_by_text", "Click a button in the running game by its text label. Finds the button, calculates its center, and simulates a full click (press + release). Much easier than manual coordinate-based clicking.", { text: z .string() .describe("Button text to search for (e.g. 'New Game', 'Start', 'OK')"), partial: z .boolean() .optional() .describe("Allow partial text matching (default: true). If false, requires exact match."), }, async (params) => { try { const result = await godot.sendCommand("click_button_by_text", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("wait_for_node", "Wait until a node exists at the given path in the running game scene tree. Useful for waiting after scene transitions, node spawning, or UI state changes.", { node_path: z .string() .describe("Absolute node path to wait for (e.g. '/root/Main/Player', '/root/Dungeon')"), timeout: z .number() .optional() .describe("Maximum seconds to wait (default: 5.0)"), poll_frames: z .number() .optional() .describe("Frames between each check (default: 5, i.e. ~12 checks/sec at 60fps)"), }, async (params) => { try { const result = await godot.sendCommand("wait_for_node", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("find_nearby_nodes", "Find all nodes within a radius of a position in the running game, sorted by distance. Useful for finding what's near the player (collectibles, enemies, interactables) without manually querying each node's position.", { position: z .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number().optional() })]) .describe("Origin position: either a node_path string (e.g. '/root/Main/Player') to use that node's global_position, or an {x, y, z} coordinate object"), radius: coerceNumber() .optional() .describe("Search radius in world units (default: 20.0)"), type_filter: z .string() .optional() .describe("Only include nodes of this Godot class (e.g. 'Area3D', 'CharacterBody3D')"), group_filter: z .string() .optional() .describe("Only include nodes in this group (e.g. 'enemies', 'collectibles')"), max_results: coerceNumber() .optional() .describe("Maximum number of results to return (default: 10)"), }, async (params) => { try { const result = await godot.sendCommand("find_nearby_nodes", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("navigate_to", "Calculate navigation info from the player to a target in the running 3D game. Returns the world direction, camera-relative suggested WASD keys to press, camera yaw rotation needed (as mouse relative_x pixels for simulate_mouse_move), and estimated walk duration. Use this to plan movement instead of manually calculating directions.", { target: z .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number() })]) .describe("Target: either a node_path string (e.g. '/root/Main/Crystal') or an {x, y, z} coordinate object"), player_path: z .string() .optional() .describe("Player node path (default: '/root/Main/Player')"), camera_path: z .string() .optional() .describe("Camera node path (default: auto-detect active Camera3D)"), move_speed: coerceNumber() .optional() .describe("Player movement speed in units/sec for duration estimation (default: 5.0)"), }, async (params) => { try { const result = await godot.sendCommand("navigate_to", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("move_to", "Autopilot the player character to walk to a target position in the running 3D game. Handles camera rotation and forward movement internally at 60fps — completes in a single call with no manual simulate_key/simulate_mouse_move loops needed. The player's camera pivot is directly rotated toward the target, and W key is injected to walk. Much more reliable and efficient than navigate_to + manual input simulation.", { target: z .union([z.string(), z.object({ x: z.number(), y: z.number(), z: z.number() })]) .describe("Target: either a node_path string (e.g. '/root/Main/Crystal') or an {x, y, z} coordinate object"), player_path: z .string() .optional() .describe("Player node path (default: '/root/Main/Player')"), camera_path: z .string() .optional() .describe("Camera pivot node path (default: auto-detect SpringArm3D child of player, or active Camera3D parent)"), arrival_radius: coerceNumber() .optional() .describe("Stop when this close to target in world units (default: 1.5)"), timeout: coerceNumber() .optional() .describe("Maximum seconds before giving up (default: 15.0)"), run: z .boolean() .optional() .describe("Hold Shift for running speed (default: false)"), look_at_target: z .boolean() .optional() .describe("Rotate camera toward target while moving (default: true). Set false to walk forward without turning."), }, async (params) => { try { const result = await godot.sendCommand("move_to", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); server.tool("batch_get_properties", "Get properties of multiple nodes at once in the running game. More efficient than calling get_game_node_properties multiple times.", { nodes: z .array(z.object({ path: z .string() .describe("Absolute node path (e.g. '/root/Main/Player')"), properties: coerceStringArray() .optional() .describe("Specific properties to read (default: all editor-visible)"), })) .describe("Array of nodes to query"), }, async (params) => { try { const result = await godot.sendCommand("batch_get_properties", params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) }, ], }; } catch (e) { return { content: [{ type: "text", text: formatErrorForMcp(e) }], isError: true, }; } }); } //# sourceMappingURL=runtime-tools.js.map