1480 lines
57 KiB
JavaScript
1480 lines
57 KiB
JavaScript
#!/usr/bin/env node
|
|
// MCP server that bridges stdio tool calls to the agent_tools plugin running
|
|
// inside the Godot editor (TCP JSON-RPC on 127.0.0.1:9920 by default).
|
|
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
ListResourcesRequestSchema,
|
|
ListResourceTemplatesRequestSchema,
|
|
ReadResourceRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import net from "node:net";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
|
|
const HOST = process.env.GODOT_AGENT_HOST || "127.0.0.1";
|
|
// GODOT_AGENT_PORT env var forces a specific target, bypassing session discovery.
|
|
// Leave unset to use the multi-session registry.
|
|
const FORCED_PORT = process.env.GODOT_AGENT_PORT ? parseInt(process.env.GODOT_AGENT_PORT, 10) : null;
|
|
const TIMEOUT_MS = parseInt(process.env.GODOT_AGENT_TIMEOUT_MS || "15000", 10);
|
|
|
|
// Multi-editor session registry — matches the plugin-side writer at
|
|
// <home>/.godot-agent-tools/sessions/<pid>.json. Each file describes one
|
|
// running Godot editor with the plugin enabled.
|
|
const SESSION_DIR = path.join(os.homedir(), ".godot-agent-tools", "sessions");
|
|
|
|
function isProcessAlive(pid) {
|
|
try {
|
|
// signal 0 doesn't send anything — just tests whether we can signal the pid.
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (e) {
|
|
// EPERM means the process exists but we can't signal it (still "alive" for us).
|
|
return e.code === "EPERM";
|
|
}
|
|
}
|
|
|
|
function listSessions() {
|
|
if (!fs.existsSync(SESSION_DIR)) return [];
|
|
let entries;
|
|
try { entries = fs.readdirSync(SESSION_DIR); }
|
|
catch { return []; }
|
|
const out = [];
|
|
for (const name of entries) {
|
|
if (!name.endsWith(".json")) continue;
|
|
const full = path.join(SESSION_DIR, name);
|
|
let parsed;
|
|
try { parsed = JSON.parse(fs.readFileSync(full, "utf8")); }
|
|
catch { continue; }
|
|
if (!parsed.pid || !parsed.port) continue;
|
|
const alive = isProcessAlive(parsed.pid);
|
|
if (!alive) {
|
|
// Clean up a stale entry opportunistically — plugin _exit_tree didn't fire
|
|
// (probably editor crashed or was killed).
|
|
try { fs.unlinkSync(full); } catch {}
|
|
continue;
|
|
}
|
|
out.push(parsed);
|
|
}
|
|
// Most recently started first.
|
|
out.sort((a, b) => (b.started_at_unix || 0) - (a.started_at_unix || 0));
|
|
return out;
|
|
}
|
|
|
|
// Tracks which session the shim is currently forwarding tool calls to. Starts
|
|
// unset; first call resolves via listSessions() or FORCED_PORT.
|
|
let activeSessionPid = null;
|
|
|
|
const TOOLS = [
|
|
{
|
|
name: "scene_inspect",
|
|
method: "scene.inspect",
|
|
description:
|
|
"Return the node tree of a scene as JSON (name, class, node_path, script, children). Omit 'path' to inspect the currently-edited scene. Read-only.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
path: {
|
|
type: "string",
|
|
description:
|
|
"Scene resource path (e.g. 'res://Main.tscn'). If omitted, uses the currently-edited scene.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_new",
|
|
method: "scene.new",
|
|
description:
|
|
"Create a new .tscn file from scratch with a root node of the given type. Opens the scene in the editor by default so subsequent scene.* calls target it.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "'res://...' ending in .tscn" },
|
|
root_type: { type: "string", default: "Node", description: "Godot class name for the root node." },
|
|
root_name: { type: "string", description: "Root node name; defaults to the class name." },
|
|
overwrite: { type: "boolean", default: false },
|
|
open_after: { type: "boolean", default: true },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_instance_packed",
|
|
method: "scene.instance_packed",
|
|
description:
|
|
"Add an existing .tscn as a sub-scene child of a node in the currently-edited scene. Refuses to instance a scene into itself.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["scene_path"],
|
|
properties: {
|
|
scene_path: { type: "string", description: "Path to the .tscn to instance." },
|
|
parent_path: { type: "string", description: "Parent NodePath; '.' for root. Defaults to '.'.", default: "." },
|
|
name: { type: "string", description: "Instance node name; defaults to the scene's root name." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_add_node",
|
|
method: "scene.add_node",
|
|
description:
|
|
"Add a new node to the currently-edited scene. Sets owner so the node persists on save.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["type"],
|
|
properties: {
|
|
type: { type: "string", description: "Godot class name, e.g. 'Node2D', 'Sprite2D', 'Label'." },
|
|
name: { type: "string", description: "Node name; defaults to the class name." },
|
|
parent_path: {
|
|
type: "string",
|
|
description: "NodePath of parent relative to scene root. '.' means root. Defaults to '.'.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_remove_node",
|
|
method: "scene.remove_node",
|
|
description: "Remove a node (and its descendants) from the currently-edited scene. Cannot remove the root.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path"],
|
|
properties: { node_path: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "scene_duplicate_node",
|
|
method: "scene.duplicate_node",
|
|
description:
|
|
"Clone a node (with descendants) in the currently-edited scene. Owner is set recursively so the duplicated subtree serializes. Defaults to adding under the source's parent; override with parent_path.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path"],
|
|
properties: {
|
|
node_path: { type: "string", description: "Source node to clone." },
|
|
new_name: { type: "string", description: "Name for the copy; defaults to auto-generated '<name>2' style." },
|
|
parent_path: { type: "string", description: "Destination parent; defaults to the source's parent." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_reparent",
|
|
method: "scene.reparent",
|
|
description:
|
|
"Move a node under a new parent in the currently-edited scene. Preserves global transform by default; refuses cycles.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "new_parent_path"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
new_parent_path: { type: "string" },
|
|
keep_global_transform: { type: "boolean", default: true },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_set_property",
|
|
method: "scene.set_property",
|
|
description:
|
|
"Set a property on a node. JSON-to-Godot coercion: " +
|
|
"primitives (bool/int/float/String/StringName) pass through; " +
|
|
"NodePath from a string; " +
|
|
"Vector2/2i/3/3i/4/4i from [x, y(, z(, w))]; " +
|
|
"Rect2/2i from [x, y, width, height]; " +
|
|
"Quaternion from [x, y, z, w]; " +
|
|
"Color from [r, g, b(, a)] or '#rrggbb(aa)'; " +
|
|
"Transform2D from {origin: [x,y], rotation: radians, scale: [x,y], skew?}; " +
|
|
"Transform3D from {origin: [x,y,z], rotation: [x,y,z] (euler rad), scale: [x,y,z]}; " +
|
|
"Basis from {rotation, scale} (same shape as Transform3D minus origin); " +
|
|
"AABB from {position: [x,y,z], size: [x,y,z]}; " +
|
|
"Plane from {normal: [x,y,z], d: float}; " +
|
|
"Resource-typed properties (e.g. a CollisionShape2D's 'shape') auto-load from 'res://...' or 'uid://...' path strings; " +
|
|
"Packed{String,Int32,Int64,Float32,Float64,Vector2,Vector3,Color}Array from plain JSON arrays (element-wise coerced); " +
|
|
"other types pass through as-is. " +
|
|
"Returns an error if Godot drops the assignment (e.g. type mismatch) instead of echoing a misleading null.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "property"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
property: { type: "string" },
|
|
value: { description: "Value, JSON-native. See description for coercion rules." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_get_property",
|
|
method: "scene.get_property",
|
|
description:
|
|
"Read a property from a node in the currently-edited scene. Mirror of scene_set_property — useful for 'what's the current value?' queries without dumping the whole tree.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "property"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
property: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_build_tree",
|
|
method: "scene.build_tree",
|
|
description:
|
|
"Build a subtree in the currently-edited scene in one call — recursive spec instead of dozens of scene_add_node + scene_set_property + script_attach round trips. " +
|
|
"Each tree entry: {type: 'ClassName' (required), name?: string, properties?: {propname: value, ...}, script?: 'res://...', children?: [entry, ...]}. " +
|
|
"Properties are coerced via the same rules as scene_set_property (Vectors from arrays, Resources auto-loaded from res:// paths, Transforms from {origin,rotation,scale}, etc.). " +
|
|
"Script is attached before properties so script-exported vars are settable in the same call. " +
|
|
"Atomic: if any entry fails (unknown type, missing property, coercion error, read-only slot, silent-null assignment), every node created during this call is rolled back — the scene is left in its pre-call state.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["nodes"],
|
|
properties: {
|
|
parent_path: { type: "string", default: ".", description: "Where to attach the new subtree. '.' = scene root." },
|
|
nodes: {
|
|
type: "array",
|
|
description: "Top-level tree entries (each may have arbitrarily nested children).",
|
|
items: {
|
|
type: "object",
|
|
required: ["type"],
|
|
properties: {
|
|
type: { type: "string", description: "Godot class name (e.g. 'PanelContainer', 'Button')." },
|
|
name: { type: "string" },
|
|
script: { type: "string", description: "Path to a .gd to attach before properties are applied." },
|
|
properties: { type: "object", additionalProperties: true, description: "Property → value pairs; values use the scene_set_property coercion rules." },
|
|
children: { type: "array", description: "Nested entries using the same shape as this one." },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_call_method",
|
|
method: "scene.call_method",
|
|
description:
|
|
"Invoke a method on a node in the currently-edited scene (e.g. a custom helper, or Godot built-ins like 'queue_free', 'add_to_group'). Args are coerced against the method's declared parameter types using the same rules as scene_set_property — including 'res://...' → Resource auto-load. Return value is JSON-native. Useful when properties alone can't express what you need — e.g. calling a script method with multiple args.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "method"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
method: { type: "string" },
|
|
args: {
|
|
type: "array",
|
|
description: "Method arguments in order. Each is coerced based on the method's declared parameter type.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "scene_open",
|
|
method: "scene.open",
|
|
description: "Open a scene in the editor so subsequent scene.* calls target it.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: { path: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "scene_save",
|
|
method: "scene.save",
|
|
description:
|
|
"Save the currently-edited scene. Pass 'path' to save-as (rebinds the scene to that path). " +
|
|
"REQUIRED for fresh scenes: if the scene has never been saved (no backing file), the tool returns an error rather than triggering Godot's native Save-As dialog (which would block the editor waiting for a human click). Always pass 'path' when you built the scene from scratch via scene.build_tree / scene.add_node without going through scene.new.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { path: { type: "string", description: "Save-as target, required when the scene has no existing file." } },
|
|
},
|
|
},
|
|
{
|
|
name: "scene_current",
|
|
method: "scene.current",
|
|
description: "Describe the currently-edited scene, or return {open: false} if none is open.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "scene_capture_screenshot",
|
|
method: "scene.capture_screenshot",
|
|
description:
|
|
"Save a PNG of the editor viewport for the open scene (2D or 3D selected automatically). Clean capture — no editor grid/gizmos. Empty scenes render as the viewport background color. Default output: res://.godot/agent_tools/<scene-name>.png.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
output: {
|
|
type: "string",
|
|
description: "Optional output path (res://...). Defaults to res://.godot/agent_tools/<scene>.png.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "signal_connect",
|
|
method: "signal.connect",
|
|
description:
|
|
"Connect a signal between two nodes in the currently-edited scene. Connection is persistent (serialized into the .tscn as a [connection] block). Validates signal name, method existence, and arity.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["from", "signal", "to", "method"],
|
|
properties: {
|
|
from: { type: "string", description: "Source node path." },
|
|
signal: { type: "string" },
|
|
to: { type: "string", description: "Target node path." },
|
|
method: { type: "string", description: "Method name on the target's script." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "signal_disconnect",
|
|
method: "signal.disconnect",
|
|
description: "Disconnect a specific signal wiring from the currently-edited scene.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["from", "signal", "to", "method"],
|
|
properties: {
|
|
from: { type: "string" },
|
|
signal: { type: "string" },
|
|
to: { type: "string" },
|
|
method: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "signal_list",
|
|
method: "signal.list",
|
|
description:
|
|
"List outgoing signal connections on a node. Each entry includes flags and a 'persistent' boolean so you can filter editor-serialized connections from runtime ones.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
persistent_only: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "script_patch",
|
|
method: "script.patch",
|
|
description:
|
|
"Apply targeted edits to an existing .gd file. Two modes: 'replacements' is an array of {old, new} where each 'old' must match exactly once in the file (ambiguous or missing matches return a clean error instead of silently mangling); 'full_source' overwrites the whole file. After writing, the tool parse-checks the result via ResourceLoader.load; if parsing fails the original is restored and an error is returned. Supports dry_run.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "Target .gd file." },
|
|
replacements: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
required: ["old", "new"],
|
|
properties: {
|
|
old: { type: "string" },
|
|
new: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
full_source: { type: "string", description: "Alternative to replacements: overwrite with this full source." },
|
|
dry_run: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "script_create",
|
|
method: "script.create",
|
|
description:
|
|
"Create a new .gd file with 'extends' and optional 'class_name', and optionally attach it to a node in the currently-edited scene.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "Target path, e.g. 'res://scripts/Player.gd'." },
|
|
extends: { type: "string", default: "Node" },
|
|
class_name: { type: "string" },
|
|
attach_to_node: { type: "string", description: "NodePath in the currently-edited scene." },
|
|
overwrite: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "script_attach",
|
|
method: "script.attach",
|
|
description: "Attach an existing script to a node in the currently-edited scene.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "script_path"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
script_path: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "resource_create",
|
|
method: "resource.create",
|
|
description:
|
|
"Create a new .tres file. Use 'type' for built-in Resource subclasses (StyleBoxFlat, Theme, Curve, etc.) or 'script' pointing at a custom Resource .gd file. Optional 'properties' are applied before saving.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "Target path, e.g. 'res://themes/main.tres'." },
|
|
type: { type: "string", description: "Built-in Resource class name." },
|
|
script: { type: "string", description: "Path to a custom Resource .gd (use instead of 'type')." },
|
|
properties: {
|
|
type: "object",
|
|
description: "Initial property values. Same coercion rules as scene_set_property.",
|
|
additionalProperties: true,
|
|
},
|
|
overwrite: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "resource_set_property",
|
|
method: "resource.set_property",
|
|
description:
|
|
"Load a .tres, set one property, and save. Same JSON-to-Godot coercion as scene_set_property: primitives, NodePath, Vector2/2i/3/3i/4/4i, Rect2/2i, Quaternion, Color, Transform2D/3D, Basis, AABB, Plane, Resource auto-load from 'res://'/'uid://' paths, and all Packed*Array variants. Errors on silently-dropped assignments.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "property"],
|
|
properties: {
|
|
path: { type: "string" },
|
|
property: { type: "string" },
|
|
value: { description: "Value, JSON-native." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "resource_call_method",
|
|
method: "resource.call_method",
|
|
description:
|
|
"Load a .tres, invoke a method on it, save, return the method's result. Rounds out what set_property can't express — e.g. StyleBoxFlat.set_border_width_all(4), set_corner_radius_all(14), Curve.add_point(...). Args coerce via the same rules as resource_set_property. Pass save:false to call without persisting (read-only method calls).",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "method"],
|
|
properties: {
|
|
path: { type: "string" },
|
|
method: { type: "string" },
|
|
args: { type: "array", description: "Method arguments in order, coerced per the method's parameter types." },
|
|
save: { type: "boolean", default: true, description: "Save the resource after the call. Set false for read-only method calls." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "refs_validate_project",
|
|
method: "refs.validate_project",
|
|
description:
|
|
"Project-wide scan for broken references: unparseable scripts, missing ext_resources, dangling UIDs, signal connections pointing at missing nodes or methods.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "refs_find_usages",
|
|
method: "refs.find_usages",
|
|
description:
|
|
"Find every file referencing a resource. Accepts a path or a uid:// — searches for both forms so uid-indirected references are caught. Returns file path, line number, and matched text.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["target"],
|
|
properties: {
|
|
target: { type: "string", description: "'res://...' or 'uid://...'" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "refs_rename",
|
|
method: "refs.rename",
|
|
description:
|
|
"Move a file and rewrite every path-form reference to it. The .uid and .import sidecars are moved too so UID-form references keep resolving. Supports dry_run to preview changes without touching disk.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["from", "to"],
|
|
properties: {
|
|
from: { type: "string" },
|
|
to: { type: "string" },
|
|
overwrite: { type: "boolean", default: false },
|
|
dry_run: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "refs_rename_class",
|
|
method: "refs.rename_class",
|
|
description:
|
|
"Rename 'class_name X' to 'class_name Y' across the project — updates the defining script and every word-boundary reference in .gd / .tscn / .tres files. Best-effort (won't distinguish an X that happens to be a local variable); use dry_run first.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["from", "to"],
|
|
properties: {
|
|
from: { type: "string" },
|
|
to: { type: "string" },
|
|
dry_run: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "project_get_setting",
|
|
method: "project.get_setting",
|
|
description: "Read a project.godot setting by key (e.g. 'application/config/name'). Returns {exists: false} if unset.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["key"],
|
|
properties: { key: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "project_set_setting",
|
|
method: "project.set_setting",
|
|
description:
|
|
"Write a project.godot setting and save. DESTRUCTIVE — mutates project.godot. Prefer specific tools (autoload_add, etc.) when available.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["key", "value"],
|
|
properties: {
|
|
key: { type: "string" },
|
|
value: { description: "JSON-native value." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "autoload_add",
|
|
method: "autoload.add",
|
|
description: "Register an autoload. Singleton by default (adds the '*' prefix so the name is globally accessible).",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["name", "path"],
|
|
properties: {
|
|
name: { type: "string", description: "Globally-accessible name, e.g. 'GameState'." },
|
|
path: { type: "string", description: "Path to a .gd or .tscn, e.g. 'res://autoload/game_state.gd'." },
|
|
singleton: { type: "boolean", default: true },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "autoload_remove",
|
|
method: "autoload.remove",
|
|
description: "Unregister an autoload by name.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["name"],
|
|
properties: { name: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "autoload_list",
|
|
method: "autoload.list",
|
|
description: "List all registered autoloads with their paths and singleton flags.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "editor_state",
|
|
method: "editor.state",
|
|
description:
|
|
"Consolidated editor + project status in one call: Godot version, project name, current scene (path/class/root_name/open), list of open scenes, is-playing flag, playing scene path.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "editor_selection_get",
|
|
method: "editor.selection_get",
|
|
description: "Return the currently-selected nodes in the editor tree dock — for 'operate on what I clicked' workflows.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "editor_selection_set",
|
|
method: "editor.selection_set",
|
|
description: "Select specific nodes in the editor tree dock. Useful after an agent operation to point the user's attention at the result.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_paths"],
|
|
properties: {
|
|
node_paths: { type: "array", items: { type: "string" }, description: "NodePaths relative to the scene root. '.' = root." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "editor_game_screenshot",
|
|
method: "editor.game_screenshot",
|
|
description:
|
|
"Capture the viewport of the CURRENTLY RUNNING game (after user pressed F5 etc.). Works via the _MCPGameBridge autoload registered by this plugin. If no game is running, returns an error pointing the user to run.scene_headless as the subprocess-based alternative.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
output: { type: "string", default: "res://.godot/agent_tools/game_screenshot.png" },
|
|
timeout_ms: { type: "integer", default: 5000 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "logs_read",
|
|
method: "logs.read",
|
|
description:
|
|
"Read print / push_error / push_warning output from the currently running game (captured by the _MCPGameBridge autoload). Entries include level, message, and timestamp. Returns an empty buffer with a helpful note if the game isn't running.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
clear: { type: "boolean", default: false, description: "Clear the buffer after reading." },
|
|
max_lines: { type: "integer", default: 200, description: "Cap on entries returned; older entries are omitted first." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "logs_clear",
|
|
method: "logs.clear",
|
|
description: "Drop the game log buffer. Safe to call whether the game is running or not.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "performance_monitors",
|
|
method: "performance.monitors",
|
|
description:
|
|
"Read Godot's Performance monitors (FPS, frame time, memory, object/node counts, draw calls, etc.). Default returns a common set; pass 'monitors' with specific names (fps, frame_time, mem_static, draw_calls, orphan_nodes, ...) for targeted reads.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
monitors: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "Optional subset of monitor names. Full list: fps, frame_time, physics_time, mem_static, mem_static_max, objects, resources, nodes, orphan_nodes, draw_calls, primitives, 2d_items, 2d_draw_calls, video_mem, audio_latency, physics_2d_active_objects, physics_3d_active_objects.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "test_run",
|
|
method: "test.run",
|
|
description:
|
|
"Detect and run a GDScript test framework (GUT or GdUnit4), return structured results. Auto-detects the installed framework (via addons/gut or addons/gdUnit4), can be forced via 'framework'. Returns {total, passed, failed, skipped, failures: [{name, file, line, message}], raw_output}. Higher level than run.scene_headless — understands the framework's test concepts and summary format instead of asking you to parse stdout.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
framework: { type: "string", enum: ["auto", "gut", "gdunit4"], default: "auto" },
|
|
directory: { type: "string", description: "Test directory (defaults to 'res://test')." },
|
|
pattern: { type: "string", description: "Filename pattern (framework-specific default)." },
|
|
timeout_seconds: { type: "integer", default: 60 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "client_list",
|
|
method: "client.list",
|
|
description: "List supported MCP clients and whether each has the godot-agent-tools server configured. Shows the config file path for every client so users know where to look.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "client_configure",
|
|
method: "client.configure",
|
|
description:
|
|
"Write the godot-agent-tools MCP server entry into the specified client's config file. Idempotent (won't duplicate); pass overwrite:true to force-replace an existing entry. Supported clients: claude_code_project, claude_code_user, claude_desktop, cursor_project, cursor_user.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["client"],
|
|
properties: {
|
|
client: { type: "string", enum: ["claude_code_project", "claude_code_user", "claude_desktop", "cursor_project", "cursor_user"] },
|
|
overwrite: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "client_remove",
|
|
method: "client.remove",
|
|
description: "Remove the godot-agent-tools entry from the specified client's config.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["client"],
|
|
properties: {
|
|
client: { type: "string", enum: ["claude_code_project", "claude_code_user", "claude_desktop", "cursor_project", "cursor_user"] },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "physics_autofit_collision_shape_2d",
|
|
method: "physics.autofit_collision_shape_2d",
|
|
description:
|
|
"Compute a CollisionShape2D sized to a sibling Sprite2D/AnimatedSprite2D's visual bounds. Can auto-create the CollisionShape2D if it doesn't exist yet (pass create:true). Shape type: 'rectangle' (default), 'circle', or 'capsule'. Optional margin shrinks the shape.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path"],
|
|
properties: {
|
|
node_path: { type: "string", description: "CollisionShape2D to fit. Created if missing + create:true." },
|
|
source: { type: "string", description: "NodePath to a Sprite2D/AnimatedSprite2D. Auto-detected among siblings if omitted." },
|
|
shape: { type: "string", enum: ["rectangle", "circle", "capsule"], default: "rectangle" },
|
|
margin: { type: "number", default: 0 },
|
|
create: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "theme_set_color",
|
|
method: "theme.set_color",
|
|
description: "Set a color entry in a Theme resource. Wraps Theme.set_color(item, type, color) — e.g. item='font_color', type='Label'.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "item", "type", "color"],
|
|
properties: {
|
|
path: { type: "string", description: "Path to .tres Theme resource." },
|
|
item: { type: "string", description: "Theme item name (e.g. 'font_color', 'bg_color')." },
|
|
type: { type: "string", description: "Control class name (e.g. 'Button', 'Label')." },
|
|
color: { description: "[r,g,b(,a)] or '#hex'." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "theme_set_constant",
|
|
method: "theme.set_constant",
|
|
description: "Set an int constant in a Theme resource. E.g. item='h_separation', type='HBoxContainer'.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "item", "type", "value"],
|
|
properties: {
|
|
path: { type: "string" },
|
|
item: { type: "string" },
|
|
type: { type: "string" },
|
|
value: { type: "integer" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "theme_set_font_size",
|
|
method: "theme.set_font_size",
|
|
description: "Set a font-size entry in a Theme resource. E.g. item='font_size', type='Label'.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "item", "type", "value"],
|
|
properties: {
|
|
path: { type: "string" },
|
|
item: { type: "string" },
|
|
type: { type: "string" },
|
|
value: { type: "integer" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "theme_set_stylebox_flat",
|
|
method: "theme.set_stylebox_flat",
|
|
description:
|
|
"Create (or replace) a StyleBoxFlat on a Theme with the given properties and assign it to theme.<item>.<type>. Saves the usual multi-step StyleBoxFlat setup — e.g. {item: 'normal', type: 'Button', properties: {bg_color: [0.1,0.1,0.12,1], corner_radius_top_left: 8, ...}}.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "item", "type"],
|
|
properties: {
|
|
path: { type: "string" },
|
|
item: { type: "string" },
|
|
type: { type: "string" },
|
|
properties: { type: "object", additionalProperties: true },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "session_list",
|
|
method: "__local__.session_list",
|
|
description:
|
|
"List every running Godot editor with the Agent Tools plugin enabled (each becomes a separate 'session' the shim can target). Each entry: {pid, port, project_path, project_name, godot_version, started_at_unix, active}. The shim's default target is the most-recently-started session; use session_activate to pin a specific one.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "session_activate",
|
|
method: "__local__.session_activate",
|
|
description:
|
|
"Pin subsequent tool calls to a specific Godot editor session (by pid from session_list). Pass pid:null to clear the pin and fall back to 'most-recently-started'. Changing the active session tears down the existing TCP connection; the next call reconnects to the new target.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
pid: { type: ["integer", "null"], description: "PID of the session to target; null to clear." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "batch_execute",
|
|
method: "batch.execute",
|
|
description:
|
|
"Run multiple tool calls in one round trip. Each call is dispatched server-side and results are returned in order. Useful when you know the exact sequence you want — saves TCP round trips vs. parallel MCP calls.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["calls"],
|
|
properties: {
|
|
calls: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
required: ["method"],
|
|
properties: {
|
|
method: { type: "string", description: "Dotted method name (e.g. 'scene.add_node')." },
|
|
params: { type: "object" },
|
|
},
|
|
},
|
|
},
|
|
stop_on_error: { type: "boolean", default: false, description: "Halt the batch on the first failure. Default keeps going." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "editor_reload_filesystem",
|
|
method: "editor.reload_filesystem",
|
|
description:
|
|
"Trigger an editor filesystem rescan. Call this after creating/moving/deleting files via tools that bypass the editor, so load() and the FileSystem dock see the changes.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "editor_save_all_scenes",
|
|
method: "editor.save_all_scenes",
|
|
description: "Save every currently-open scene in the editor.",
|
|
inputSchema: { type: "object", properties: {} },
|
|
},
|
|
{
|
|
name: "docs_class_ref",
|
|
method: "docs.class_ref",
|
|
description:
|
|
"Return the public API of a Godot class (methods, properties, signals, constants) so the agent can plan calls without guessing. Defaults to class-local members; pass include_inherited:true to include ancestors.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["class_name"],
|
|
properties: {
|
|
class_name: { type: "string", description: "Godot class name, e.g. 'Timer', 'AnimationPlayer'." },
|
|
include_inherited: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "input_map_add_action",
|
|
method: "input_map.add_action",
|
|
description: "Register a new input action in project.godot.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["name"],
|
|
properties: {
|
|
name: { type: "string", description: "Action name, e.g. 'jump'." },
|
|
deadzone: { type: "number", default: 0.5 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "input_map_add_event",
|
|
method: "input_map.add_event",
|
|
description:
|
|
"Attach an input event to an existing action. Every event accepts an optional 'device' field (default -1 = all devices; set 0, 1, etc. for local-multiplayer device-specific bindings). Event shapes: " +
|
|
"{type:'key', keycode:'A'|'Space'|...}; " +
|
|
"{type:'mouse_button', button_index:1|2|3}; " +
|
|
"{type:'joy_button', button_index:0..}; " +
|
|
"{type:'joy_motion', axis:0..5 or 'left_x'|'left_y'|'right_x'|'right_y'|'trigger_left'|'trigger_right', axis_value:-1.0..1.0} (axis_value sign picks direction).",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["action", "event"],
|
|
properties: {
|
|
action: { type: "string" },
|
|
event: {
|
|
type: "object",
|
|
required: ["type"],
|
|
properties: {
|
|
type: { type: "string", enum: ["key", "mouse_button", "joy_button", "joy_motion"] },
|
|
device: { type: "integer", default: -1, description: "-1 = all devices (default); 0, 1, etc. bind a specific controller for local multiplayer." },
|
|
keycode: { description: "For type='key': 'A', 'Space', 'F1', or int keycode." },
|
|
physical: { type: "boolean", default: true, description: "For type='key': use physical keycode (recommended)." },
|
|
button_index: { type: "integer", description: "For mouse/joy button events." },
|
|
axis: { description: "For type='joy_motion': int 0..5 or 'left_x'|'left_y'|'right_x'|'right_y'|'trigger_left'|'trigger_right'." },
|
|
axis_value: { type: "number", description: "For type='joy_motion': -1.0 to 1.0; sign selects direction that triggers the action." },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "input_map_list",
|
|
method: "input_map.list",
|
|
description:
|
|
"List input actions with their events. Defaults to user-defined actions only (filters out Godot's ~90 built-in ui_* defaults). Pass include_builtins:true for the full list.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
include_builtins: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "input_map_remove_event",
|
|
method: "input_map.remove_event",
|
|
description:
|
|
"Remove an event from an action by index. Call input_map_list first to see indices (events are listed in add order).",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["action", "event_index"],
|
|
properties: {
|
|
action: { type: "string" },
|
|
event_index: { type: "integer" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "input_map_remove_action",
|
|
method: "input_map.remove_action",
|
|
description: "Delete a user-registered input action.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["name"],
|
|
properties: { name: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "run_scene_headless",
|
|
method: "run.scene_headless",
|
|
description:
|
|
"Run a scene in a child Godot process with structured output. " +
|
|
"MODES: BARE (default) runs --headless — fast, no window, good for 'does _ready not crash' checks. " +
|
|
"DRIVEN (anything beyond path + quit_after_seconds) runs under a wrapper driver that can inject scripted input, capture screenshots at multiple frames, dump final scene state as JSON, and use a deterministic RNG seed. " +
|
|
"SCREENSHOTS: when screenshot(s) requested the subprocess drops --headless and runs with a real (offscreen) window because Godot 4.6's headless mode uses a dummy renderer. Expect a brief window flash. " +
|
|
"STRUCTURED RESULTS: tool parses stdout for ERROR: / USER ERROR: / SCRIPT ERROR: / WARNING: / USER WARNING: lines and returns them as result.errors and result.warnings arrays so the agent doesn't have to regex the raw output. state_dump:true adds result.final_state with the scene tree + common properties. " +
|
|
"Event types for input_script: " +
|
|
"{frame, type: 'action_tap', action}; " +
|
|
"{frame, type: 'action_press', action, strength?}; " +
|
|
"{frame, type: 'action_release', action}; " +
|
|
"{frame, type: 'key', keycode: 'Space'|int, pressed?: true}; " +
|
|
"{frame, type: 'mouse_click', position: [x, y], button?: 1}; " +
|
|
"{frame, type: 'mouse_motion', position: [x, y]}. " +
|
|
"BLOCKS the editor for the duration — use small quit_after_seconds (1-5).",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "Scene to run, e.g. 'res://Main.tscn'." },
|
|
quit_after_seconds: { type: "number", default: 2, description: "Converted to frames assuming 60 fps. In DRIVEN mode this is also the quit_frame the driver targets." },
|
|
extra_args: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "Additional CLI args passed to the child godot process.",
|
|
},
|
|
input_script: {
|
|
type: "array",
|
|
description: "Optional: enters DRIVEN mode. Array of event specs keyed by frame. See description for event type shapes.",
|
|
items: {
|
|
type: "object",
|
|
required: ["frame", "type"],
|
|
properties: {
|
|
frame: { type: "integer", description: "0-based frame to fire on." },
|
|
type: { type: "string", enum: ["action_press", "action_release", "action_tap", "key", "mouse_click", "mouse_motion"] },
|
|
action: { type: "string" },
|
|
strength: { type: "number", default: 1.0 },
|
|
keycode: { description: "For type='key': 'A', 'Space', 'F1', or int keycode." },
|
|
pressed: { type: "boolean" },
|
|
button: { type: "integer", description: "Mouse button (1=left, 2=right, 3=middle)." },
|
|
position: { type: "array", description: "[x, y] viewport coordinates." },
|
|
},
|
|
},
|
|
},
|
|
screenshot: {
|
|
type: "string",
|
|
description: "Shorthand: one PNG saved at the final frame. Equivalent to screenshots:[{frame: quit_frame, path: ...}]. Triggers offscreen-windowed subprocess (brief window flash).",
|
|
},
|
|
screenshots: {
|
|
type: "array",
|
|
description: "Capture PNGs at multiple specific frames during the run — useful for verifying state transitions (spawn at frame 30, mid-animation at 60, final at 120).",
|
|
items: {
|
|
type: "object",
|
|
required: ["frame", "path"],
|
|
properties: {
|
|
frame: { type: "integer" },
|
|
path: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
resolution: {
|
|
type: "string",
|
|
default: "320x240",
|
|
description: "Window resolution 'WxH' when screenshots are captured. Default is tiny to minimize offscreen footprint; bump to 1280x720 or similar for UI verification.",
|
|
},
|
|
state_dump: {
|
|
type: "boolean",
|
|
default: false,
|
|
description: "When true, driver writes a JSON snapshot of the final scene tree (name, class, node_path, script, children, plus common props like visible/position/text/value/modulate). Returned as result.final_state. Lets agents verify end state programmatically instead of eyeballing the screenshot.",
|
|
},
|
|
seed: {
|
|
type: "integer",
|
|
description: "Optional RNG seed. Set before the target scene is instanced so randi/randf are reproducible. Useful for deterministic tests.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "fs_read_text",
|
|
method: "fs.read_text",
|
|
description: "Read a text file under res://. Complement to user_fs_read (which targets user:// for runtime-written state).",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: { path: { type: "string", description: "Must begin with 'res://'." } },
|
|
},
|
|
},
|
|
{
|
|
name: "fs_write_text",
|
|
method: "fs.write_text",
|
|
description:
|
|
"Write a text file under res://. Creates parent directories if needed; triggers the editor's filesystem rescan so the new file shows up in the FileSystem dock immediately. For .gd scripts prefer script_create / script_patch — those run a parse check.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path", "content"],
|
|
properties: {
|
|
path: { type: "string" },
|
|
content: { type: "string" },
|
|
overwrite: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user_fs_read",
|
|
method: "user_fs.read",
|
|
description:
|
|
"Read a text file from the user:// data directory — where games persist save files, custom-level JSON, settings, etc. Separate from fs_list (which is res://-only) because user:// is runtime-written state.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["path"],
|
|
properties: {
|
|
path: { type: "string", description: "Must begin with 'user://'." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user_fs_list",
|
|
method: "user_fs.list",
|
|
description:
|
|
"List files and subdirectories under a user:// directory. Optionally recursive.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
dir: { type: "string", default: "user://", description: "Must begin with 'user://'." },
|
|
recursive: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "fs_list",
|
|
method: "fs.list",
|
|
description:
|
|
"Enumerate project files by type with optional glob filter. Types: all | scene | script | resource | shader | image | audio. Skips the agent_tools addon by default.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
type: {
|
|
type: "string",
|
|
enum: ["all", "scene", "script", "resource", "shader", "image", "audio"],
|
|
default: "all",
|
|
},
|
|
glob: { type: "string", description: "Optional case-insensitive glob, e.g. 'res://scenes/**/*.tscn'." },
|
|
include_addons: { type: "boolean", default: false },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "animation_list",
|
|
method: "animation.list",
|
|
description: "List animations on an AnimationPlayer node with their tracks.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path"],
|
|
properties: { node_path: { type: "string" } },
|
|
},
|
|
},
|
|
{
|
|
name: "animation_add_animation",
|
|
method: "animation.add_animation",
|
|
description: "Create an empty animation in the player's library. Use animation_add_value_track to populate it.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "name"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
name: { type: "string" },
|
|
length: { type: "number", default: 1.0 },
|
|
library: { type: "string", default: "", description: "Library name; '' is the default library." },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "animation_remove_animation",
|
|
method: "animation.remove_animation",
|
|
description: "Delete an animation from an AnimationPlayer.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "name"],
|
|
properties: {
|
|
node_path: { type: "string" },
|
|
name: { type: "string" },
|
|
library: { type: "string", default: "" },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "animation_add_value_track",
|
|
method: "animation.add_value_track",
|
|
description:
|
|
"Add a value track to an animation that animates a property on a target node. target_node is resolved relative to the AnimationPlayer's root. Auto-extends the animation's length if keyframes go past it.",
|
|
inputSchema: {
|
|
type: "object",
|
|
required: ["node_path", "animation", "target_node", "property", "keyframes"],
|
|
properties: {
|
|
node_path: { type: "string", description: "AnimationPlayer node path." },
|
|
animation: { type: "string", description: "Animation name — use 'lib/anim' for non-default libraries." },
|
|
target_node: { type: "string", description: "NodePath to animated node, relative to the player's root." },
|
|
property: { type: "string", description: "Property name to animate." },
|
|
keyframes: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
required: ["time", "value"],
|
|
properties: {
|
|
time: { type: "number" },
|
|
value: { description: "Property value at this time." },
|
|
easing: { type: "number", default: 1.0, description: "Transition curve; 1.0 = linear." },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
|
|
|
|
// Persistent Godot TCP client. One socket reused across tool calls; outstanding
|
|
// requests are tracked by id so multiple in-flight calls don't interleave data.
|
|
// Target port is resolved lazily per call so session.activate / session death
|
|
// switch over without proactive teardown.
|
|
class GodotClient {
|
|
constructor(host) {
|
|
this.host = host;
|
|
this.port = null;
|
|
this.socket = null;
|
|
this.buffer = "";
|
|
this.pending = new Map(); // id -> { resolve, reject, timer }
|
|
this.nextId = 0;
|
|
this.connecting = null;
|
|
}
|
|
|
|
// Priority: GODOT_AGENT_PORT env > pinned active session > most recent session.
|
|
_resolvePort() {
|
|
if (FORCED_PORT != null) return FORCED_PORT;
|
|
const sessions = listSessions();
|
|
if (sessions.length === 0) return null;
|
|
if (activeSessionPid != null) {
|
|
const pinned = sessions.find((s) => s.pid === activeSessionPid);
|
|
if (pinned) return pinned.port;
|
|
activeSessionPid = null; // pinned session died — fall back
|
|
}
|
|
return sessions[0].port;
|
|
}
|
|
|
|
// If the active session changed, drop the old socket so the next call
|
|
// reconnects to the new target.
|
|
_maybeResetForPortChange() {
|
|
const target = this._resolvePort();
|
|
if (target !== this.port && this.socket) {
|
|
try { this.socket.destroy(); } catch {}
|
|
this.socket = null;
|
|
this.buffer = "";
|
|
for (const { reject, timer } of this.pending.values()) {
|
|
clearTimeout(timer);
|
|
reject(new Error("session target changed mid-flight"));
|
|
}
|
|
this.pending.clear();
|
|
}
|
|
this.port = target;
|
|
}
|
|
|
|
async _ensureConnected() {
|
|
this._maybeResetForPortChange();
|
|
if (this.socket && !this.socket.destroyed) return;
|
|
if (this.connecting) return this.connecting;
|
|
if (this.port == null) {
|
|
throw new Error(
|
|
"No Godot editor session found. Open a project with the 'Agent Tools' plugin enabled, " +
|
|
"or set the GODOT_AGENT_PORT env var to target a specific port."
|
|
);
|
|
}
|
|
|
|
this.connecting = new Promise((resolve, reject) => {
|
|
const s = new net.Socket();
|
|
s.setEncoding("utf8");
|
|
s.setNoDelay(true);
|
|
|
|
const onConnect = () => {
|
|
s.removeListener("error", onErrorPreConnect);
|
|
this.socket = s;
|
|
this.connecting = null;
|
|
resolve();
|
|
};
|
|
|
|
const onErrorPreConnect = (e) => {
|
|
this.connecting = null;
|
|
if (e.code === "ECONNREFUSED") {
|
|
reject(new Error(
|
|
`Godot editor not reachable on ${this.host}:${this.port}. ` +
|
|
`Open the project in the Godot editor with the 'Agent Tools' plugin enabled.`
|
|
));
|
|
} else {
|
|
reject(e);
|
|
}
|
|
};
|
|
|
|
s.once("connect", onConnect);
|
|
s.once("error", onErrorPreConnect);
|
|
|
|
s.on("data", (data) => this._onData(data));
|
|
s.on("error", (e) => this._onFatalError(e));
|
|
s.on("close", () => this._onClose());
|
|
|
|
s.connect(this.port, this.host);
|
|
});
|
|
|
|
return this.connecting;
|
|
}
|
|
|
|
call(method, params) {
|
|
const id = ++this.nextId;
|
|
return new Promise(async (resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
if (this.pending.has(id)) {
|
|
this.pending.delete(id);
|
|
reject(new Error(`Godot tool '${method}' timed out after ${TIMEOUT_MS}ms`));
|
|
}
|
|
}, TIMEOUT_MS);
|
|
|
|
this.pending.set(id, { resolve, reject, timer });
|
|
|
|
try {
|
|
await this._ensureConnected();
|
|
} catch (e) {
|
|
clearTimeout(timer);
|
|
this.pending.delete(id);
|
|
reject(e);
|
|
return;
|
|
}
|
|
|
|
const line = JSON.stringify({ id, method, params: params || {} }) + "\n";
|
|
this.socket.write(line);
|
|
});
|
|
}
|
|
|
|
_onData(data) {
|
|
this.buffer += data;
|
|
while (true) {
|
|
const nl = this.buffer.indexOf("\n");
|
|
if (nl < 0) break;
|
|
const line = this.buffer.slice(0, nl);
|
|
this.buffer = this.buffer.slice(nl + 1);
|
|
if (!line) continue;
|
|
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(line);
|
|
} catch {
|
|
continue; // ignore malformed lines — shouldn't happen
|
|
}
|
|
|
|
const pending = this.pending.get(msg.id);
|
|
if (!pending) continue;
|
|
this.pending.delete(msg.id);
|
|
clearTimeout(pending.timer);
|
|
|
|
if (msg.error) {
|
|
pending.reject(new Error(`Godot error ${msg.error.code}: ${msg.error.message}`));
|
|
} else {
|
|
pending.resolve(msg.result);
|
|
}
|
|
}
|
|
}
|
|
|
|
_onFatalError(e) {
|
|
for (const { reject, timer } of this.pending.values()) {
|
|
clearTimeout(timer);
|
|
reject(e);
|
|
}
|
|
this.pending.clear();
|
|
if (this.socket) {
|
|
this.socket.destroy();
|
|
this.socket = null;
|
|
}
|
|
}
|
|
|
|
_onClose() {
|
|
for (const { reject, timer } of this.pending.values()) {
|
|
clearTimeout(timer);
|
|
reject(new Error("Godot closed the connection"));
|
|
}
|
|
this.pending.clear();
|
|
this.socket = null;
|
|
this.buffer = "";
|
|
}
|
|
}
|
|
|
|
const client = new GodotClient(HOST);
|
|
|
|
const server = new Server(
|
|
{ name: "godot-agent-tools", version: "0.3.1" },
|
|
{ capabilities: { tools: {}, resources: {} } }
|
|
);
|
|
|
|
// MCP Resources — subscribable read-only endpoints. Agents that support
|
|
// resources can 'watch' these without repeatedly calling tools.
|
|
const RESOURCES = [
|
|
{
|
|
uri: "godot://editor/state",
|
|
name: "Editor state",
|
|
description: "Current editor state: Godot version, project name, current scene, playing status.",
|
|
mimeType: "application/json",
|
|
method: "editor.state",
|
|
},
|
|
{
|
|
uri: "godot://scene/current",
|
|
name: "Current scene",
|
|
description: "Currently-edited scene (path, root name, root class, open?).",
|
|
mimeType: "application/json",
|
|
method: "scene.current",
|
|
},
|
|
{
|
|
uri: "godot://scene/hierarchy",
|
|
name: "Current scene hierarchy",
|
|
description: "Full tree of the currently-edited scene.",
|
|
mimeType: "application/json",
|
|
method: "scene.inspect",
|
|
},
|
|
{
|
|
uri: "godot://selection/current",
|
|
name: "Editor selection",
|
|
description: "Nodes currently selected in the editor tree dock.",
|
|
mimeType: "application/json",
|
|
method: "editor.selection_get",
|
|
},
|
|
{
|
|
uri: "godot://logs/recent",
|
|
name: "Recent game logs",
|
|
description: "Recent print / push_error / push_warning output from the running game.",
|
|
mimeType: "application/json",
|
|
method: "logs.read",
|
|
},
|
|
{
|
|
uri: "godot://performance/monitors",
|
|
name: "Performance monitors",
|
|
description: "FPS, frame time, memory, draw calls, object counts.",
|
|
mimeType: "application/json",
|
|
method: "performance.monitors",
|
|
},
|
|
];
|
|
|
|
const RESOURCE_BY_URI = Object.fromEntries(RESOURCES.map((r) => [r.uri, r]));
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: TOOLS.map(({ name, description, inputSchema }) => ({
|
|
name,
|
|
description,
|
|
inputSchema,
|
|
})),
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
const tool = BY_NAME[req.params.name];
|
|
if (!tool) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
|
|
};
|
|
}
|
|
|
|
// session_list / session_activate are shim-local — they manage the MCP shim's
|
|
// own routing state and don't forward to any Godot process.
|
|
if (tool.method === "__local__.session_list") {
|
|
const all = listSessions();
|
|
// Default target is the most-recently-started session (all[0]). Compare by
|
|
// pid — listSessions() returns freshly-parsed objects each call, so object
|
|
// identity (s === listSessions()[0]) would never hold.
|
|
const defaultPid = all.length > 0 ? all[0].pid : null;
|
|
const sessions = all.map((s) => ({
|
|
...s,
|
|
active: activeSessionPid != null ? s.pid === activeSessionPid : s.pid === defaultPid,
|
|
}));
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify({ sessions, count: sessions.length, active_pid: activeSessionPid }, null, 2) }],
|
|
};
|
|
}
|
|
if (tool.method === "__local__.session_activate") {
|
|
const pid = req.params.arguments?.pid ?? null;
|
|
if (pid === null) {
|
|
activeSessionPid = null;
|
|
} else {
|
|
const sessions = listSessions();
|
|
if (!sessions.some((s) => s.pid === pid)) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text", text: `No active session with pid=${pid}. Call session_list to see candidates.` }],
|
|
};
|
|
}
|
|
activeSessionPid = pid;
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify({ active_pid: activeSessionPid }, null, 2) }],
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await client.call(tool.method, req.params.arguments || {});
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text", text: e.message }],
|
|
};
|
|
}
|
|
});
|
|
|
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
resources: RESOURCES.map(({ uri, name, description, mimeType }) => ({
|
|
uri,
|
|
name,
|
|
description,
|
|
mimeType,
|
|
})),
|
|
}));
|
|
|
|
// resources/templates/list — this server exposes only fixed-URI resources, not
|
|
// URI templates. But MCP clients (e.g. VS Code's Continue plugin) probe this
|
|
// method during connection setup; without a handler the SDK answers -32601
|
|
// "Method not found", which Continue surfaces as a scary "Error loading resource
|
|
// templates" prompt. Answer with an empty template list so the probe succeeds.
|
|
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
|
resourceTemplates: [],
|
|
}));
|
|
|
|
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
const resource = RESOURCE_BY_URI[req.params.uri];
|
|
if (!resource) {
|
|
throw new Error(`Unknown resource URI: ${req.params.uri}`);
|
|
}
|
|
try {
|
|
const result = await client.call(resource.method, {});
|
|
return {
|
|
contents: [
|
|
{
|
|
uri: resource.uri,
|
|
mimeType: resource.mimeType,
|
|
text: JSON.stringify(result, null, 2),
|
|
},
|
|
],
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
contents: [
|
|
{
|
|
uri: resource.uri,
|
|
mimeType: resource.mimeType,
|
|
text: JSON.stringify({ error: e.message }, null, 2),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
});
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|