Quaternius weapons, input fixes, sound & path fixes
- 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)
This commit is contained in:
+51
@@ -0,0 +1,51 @@
|
||||
import type { OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const autoloadToolDefinitions: ToolDefinition[];
|
||||
export declare function handleListAutoloads(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
export declare function handleAddAutoload(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
export declare function handleRemoveAutoload(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
export declare function handleUpdateAutoload(args: OperationParams): {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
};
|
||||
//# sourceMappingURL=autoload-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"autoload-tools.d.ts","sourceRoot":"","sources":["../../src/tools/autoload-tools.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAkBhF,eAAO,MAAM,uBAAuB,EAAE,cAAc,EAqEnD,CAAC;AAIF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EAcxD;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EA+CtD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EA2BzD;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;EAsCzD"}
|
||||
Vendored
+191
@@ -0,0 +1,191 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { normalizeParameters, validateSubPath, validateProjectArgs, createErrorResponse, getErrorMessage, projectGodotPath, } from '../utils/godot-runner.js';
|
||||
import { parseAutoloads, addAutoloadEntry, removeAutoloadEntry, updateAutoloadEntry, } from '../utils/autoload-ini.js';
|
||||
// --- Tool definitions ---
|
||||
export const autoloadToolDefinitions = [
|
||||
{
|
||||
name: 'list_autoloads',
|
||||
description: 'List all registered autoloads in a project with paths and singleton status. Use first when diagnosing headless failures — broken autoloads crash all headless ops, so this tells you what is loaded. No Godot process required (reads project.godot directly). Returns: [{ name, path, singleton }].',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_autoload',
|
||||
description: 'Register a new autoload in a project. autoloadPath accepts "res://..." or a project-relative path (auto-prefixed). singleton defaults true (accessible globally by name). No Godot process required. Warning: autoloads initialize in headless mode — a broken script will crash every subsequent headless op; validate before adding. Returns plain-text confirmation with the registered name, path, and singleton flag. Errors if an autoload with the same name already exists; use update_autoload to modify.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
autoloadName: {
|
||||
type: 'string',
|
||||
description: 'Name of the autoload node (e.g. "MyManager")',
|
||||
},
|
||||
autoloadPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the script or scene (e.g. "res://autoload/my_manager.gd" or "autoload/my_manager.gd")',
|
||||
},
|
||||
singleton: {
|
||||
type: 'boolean',
|
||||
description: 'Register as a globally accessible singleton by name (default: true)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'autoloadName', 'autoloadPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'remove_autoload',
|
||||
description: 'Unregister an autoload from a project by name. Use to recover from a broken autoload that is crashing headless ops. No Godot process required. Returns plain-text confirmation on success. Errors if no autoload with that name exists.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
autoloadName: { type: 'string', description: 'Name of the autoload to remove' },
|
||||
},
|
||||
required: ['projectPath', 'autoloadName'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_autoload',
|
||||
description: "Modify an existing autoload's path or singleton flag. Pass either or both — omitted fields keep their current value. Use instead of remove_autoload + add_autoload (single edit, no orphan window). No Godot process required. Returns plain-text confirmation on success. Errors if autoloadName is not registered.",
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
autoloadName: { type: 'string', description: 'Name of the autoload to update' },
|
||||
autoloadPath: { type: 'string', description: 'New path to the script or scene' },
|
||||
singleton: { type: 'boolean', description: 'New singleton flag' },
|
||||
},
|
||||
required: ['projectPath', 'autoloadName'],
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Handlers ---
|
||||
export function handleListAutoloads(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const autoloads = parseAutoloads(projectFile);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(autoloads) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to list autoloads: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export function handleAddAutoload(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.autoloadName || !args.autoloadPath) {
|
||||
return createErrorResponse('autoloadName and autoloadPath are required', [
|
||||
'Provide the autoload node name and script/scene path',
|
||||
]);
|
||||
}
|
||||
if (!validateSubPath(v.projectPath, args.autoloadPath)) {
|
||||
return createErrorResponse('Invalid autoload path', [
|
||||
'Provide a valid relative path or res:// URI that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const projectFileContent = readFileSync(projectFile, 'utf8');
|
||||
const existing = parseAutoloads(projectFile, projectFileContent);
|
||||
if (existing.some((a) => a.name === args.autoloadName)) {
|
||||
return createErrorResponse(`Autoload '${args.autoloadName}' already exists`, [
|
||||
'Use update_autoload to modify it',
|
||||
'Use list_autoloads to see current autoloads',
|
||||
]);
|
||||
}
|
||||
const isSingleton = args.singleton !== false;
|
||||
addAutoloadEntry(projectFile, args.autoloadName, args.autoloadPath, isSingleton, projectFileContent);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Autoload '${args.autoloadName}' registered at '${args.autoloadPath}' (singleton: ${isSingleton}).\nWarning: autoloads initialize in headless mode too. If this script has errors, all headless operations will fail. Verify by running get_scene_tree — if it fails, use remove_autoload to remove it.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to add autoload: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export function handleRemoveAutoload(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.autoloadName) {
|
||||
return createErrorResponse('autoloadName is required', [
|
||||
'Provide the name of the autoload to remove',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const removed = removeAutoloadEntry(projectFile, args.autoloadName);
|
||||
if (!removed) {
|
||||
return createErrorResponse(`Autoload '${args.autoloadName}' not found`, [
|
||||
'Use list_autoloads to see existing autoloads',
|
||||
]);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: `Autoload '${args.autoloadName}' removed successfully.` }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to remove autoload: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export function handleUpdateAutoload(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.autoloadName) {
|
||||
return createErrorResponse('autoloadName is required', [
|
||||
'Provide the name of the autoload to update',
|
||||
]);
|
||||
}
|
||||
if (args.autoloadPath && !validateSubPath(v.projectPath, args.autoloadPath)) {
|
||||
return createErrorResponse('Invalid autoload path', [
|
||||
'Provide a valid relative path or res:// URI that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const updated = updateAutoloadEntry(projectFile, args.autoloadName, args.autoloadPath, args.singleton);
|
||||
if (!updated) {
|
||||
return createErrorResponse(`Autoload '${args.autoloadName}' not found`, [
|
||||
'Use list_autoloads to see existing autoloads',
|
||||
'Use add_autoload to register a new one',
|
||||
]);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: `Autoload '${args.autoloadName}' updated successfully.` }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to update autoload: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=autoload-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const nodeToolDefinitions: ToolDefinition[];
|
||||
export declare function handleDeleteNodes(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleSetNodeProperties(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetNodeProperties(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleAttachScript(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetSceneTree(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleDuplicateNode(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetNodeSignals(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleConnectSignal(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleDisconnectSignal(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=node-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"node-tools.d.ts","sourceRoot":"","sources":["../../src/tools/node-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAY7F,eAAO,MAAM,mBAAmB,EAAE,cAAc,EAqS/C,CAAC;AAIF,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA6BjF;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA0BvF;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAoBvF;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAqClF;AAED,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAsBlF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA6BnF;AAED,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAsBpF;AAyCD,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAoBnF;AAED,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAoBtF"}
|
||||
Vendored
+473
@@ -0,0 +1,473 @@
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { normalizeParameters, validateSubPath, validateNodePath, createErrorResponse, validateSceneArgs, } from '../utils/godot-runner.js';
|
||||
import { executeSceneOp } from '../utils/handler-helpers.js';
|
||||
// --- Tool definitions ---
|
||||
export const nodeToolDefinitions = [
|
||||
{
|
||||
name: 'delete_nodes',
|
||||
description: 'Remove one or more nodes (and their descendants) from a scene file. Always-array: pass a single-element nodePaths array for one-off deletes. Saves once at the end. Cannot delete the scene root — that entry returns an error and the rest still process. Returns: results array with one entry per nodePath in input order (success or error message).',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Scene file path relative to the project (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
nodePaths: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Node paths from scene root to delete (e.g. ["root/Player/Sprite2D"])',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePaths'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'set_node_properties',
|
||||
description: 'Set one or more node properties on a scene in a single Godot process. Always-array: pass a single-element updates array for one-off edits. Vector2 ({x,y}), Vector3 ({x,y,z}), and Color ({r,g,b,a}) auto-convert; primitives pass through. For other complex GDScript types (Resource, NodePath, etc.), use run_script. abortOnError stops on first failure (default false continues). Saves once at the end. Returns: results[] with one entry per update in input order (success or error).',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
updates: {
|
||||
type: 'array',
|
||||
description: 'Property updates to apply',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: {
|
||||
type: 'string',
|
||||
description: 'Node path from scene root (e.g. "root/Player")',
|
||||
},
|
||||
property: {
|
||||
type: 'string',
|
||||
description: 'GDScript property name in snake_case (e.g. "position", "modulate", "collision_layer")',
|
||||
},
|
||||
value: { description: 'New property value' },
|
||||
},
|
||||
required: ['nodePath', 'property', 'value'],
|
||||
},
|
||||
},
|
||||
abortOnError: {
|
||||
type: 'boolean',
|
||||
description: 'Stop processing on first error (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'updates'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: { type: 'string' },
|
||||
property: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_properties',
|
||||
description: "Read one or more nodes' current property values from a scene file in a single Godot process. Always-array: pass a single-element nodes array for one-off reads. Per-node changedOnly:true filters out properties matching class defaults (useful for compact diffs). Returns: { results: [{ nodePath, nodeType, properties?, error? }] }; failed reads include error and omit properties.",
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodes: {
|
||||
type: 'array',
|
||||
description: 'Nodes to read properties from',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: {
|
||||
type: 'string',
|
||||
description: 'Node path from scene root (e.g. "root/Player")',
|
||||
},
|
||||
changedOnly: {
|
||||
type: 'boolean',
|
||||
description: 'Only return properties differing from defaults (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['nodePath'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodes'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'attach_script',
|
||||
description: 'Attach an existing GDScript file to a node in a scene. Use after writing the script with the standard file tools and validating it via the validate tool. Replaces any previously attached script. Saves automatically. Returns: success with the resolved nodePath and scriptPath that were attached. Errors if scriptPath does not exist or nodePath is not found.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Node path from scene root (e.g. "root/Player")' },
|
||||
scriptPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the GDScript file relative to the project (e.g. "scripts/player.gd")',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'scriptPath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
nodePath: { type: 'string' },
|
||||
scriptPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_scene_tree',
|
||||
description: 'Get the scene hierarchy as a nested tree of { name, type, path, script, children }. Use maxDepth:1 for a shallow listing of direct children only; default -1 returns the full tree. parentPath scopes the result to a subtree. Returns the nested tree as JSON text. Errors if scene does not exist or parentPath is not found.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
parentPath: {
|
||||
type: 'string',
|
||||
description: 'Scope to a subtree starting at this node path (e.g. "root/Player")',
|
||||
},
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Maximum recursion depth. -1 for unlimited (default: -1). 1 returns only direct children.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duplicate_node',
|
||||
description: 'Duplicate a node and its descendants in a Godot scene. Use to clone a configured subtree without re-creating it node-by-node via add_node. newName defaults to the original name + "2"; targetParentPath defaults to the original parent. Saves automatically. Returns: success with originalPath and the newPath where the duplicate now lives — use newPath for follow-up edits. Errors if nodePath does not exist or targetParentPath cannot accept children.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Node path from scene root to duplicate' },
|
||||
newName: {
|
||||
type: 'string',
|
||||
description: 'Name for the duplicated node (default: original name + "2")',
|
||||
},
|
||||
targetParentPath: {
|
||||
type: 'string',
|
||||
description: 'Parent node path for the duplicate (default: same parent as original)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
originalPath: { type: 'string' },
|
||||
newPath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_signals',
|
||||
description: 'List all signals defined on a node and their current connections. Use before connect_signal/disconnect_signal to verify signal/method names. The connections[].target field uses Godot absolute path format (/root/Scene/Node) — convert to scene-root-relative (root/Node) before passing to connect/disconnect_signal. Returns: nodeType and signals[], each with name and current connections (signal/target/method). Errors if node not found.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Node path from scene root (e.g. "root/Button")' },
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodePath: { type: 'string' },
|
||||
nodeType: { type: 'string' },
|
||||
signals: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
connections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signal: { type: 'string' },
|
||||
target: { type: 'string' },
|
||||
method: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'connect_signal',
|
||||
description: 'Connect a signal on a source node to a method on a target node, persisting the connection in the .tscn. Use after get_node_signals to confirm the signal name on the source and the method name on the target. Connecting the same signal+method pair twice creates a duplicate connection — call get_node_signals first if uncertain. Saves automatically. Returns a plain-text confirmation naming the source, signal, target, and method. Errors if the signal does not exist on the source node or the method does not exist on the target node.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Source node path from scene root' },
|
||||
signal: {
|
||||
type: 'string',
|
||||
description: 'Signal name on the source node (e.g. "pressed", "body_entered")',
|
||||
},
|
||||
targetNodePath: {
|
||||
type: 'string',
|
||||
description: 'Target node path from scene root that receives the signal',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
description: 'Method name on the target node to call when the signal fires',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'signal', 'targetNodePath', 'method'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'disconnect_signal',
|
||||
description: 'Remove an existing signal connection between two nodes, persisting the change in the .tscn. Use get_node_signals first to confirm the connection exists; recovery requires reconnecting via connect_signal. Saves automatically. Returns a plain-text confirmation naming the disconnected signal and target. Errors if the connection does not exist.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: { type: 'string', description: 'Source node path from scene root' },
|
||||
signal: { type: 'string', description: 'Signal name on the source node' },
|
||||
targetNodePath: { type: 'string', description: 'Target node path from scene root' },
|
||||
method: { type: 'string', description: 'Method name on the target node' },
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'signal', 'targetNodePath', 'method'],
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Handlers ---
|
||||
export async function handleDeleteNodes(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePaths || !Array.isArray(args.nodePaths) || args.nodePaths.length === 0) {
|
||||
return createErrorResponse('nodePaths array is required', [
|
||||
'Provide a non-empty array of node paths (e.g. ["root/Player"])',
|
||||
]);
|
||||
}
|
||||
for (const p of args.nodePaths) {
|
||||
if (typeof p !== 'string' || !validateNodePath(p)) {
|
||||
return createErrorResponse('Invalid nodePath in nodePaths', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodePaths: args.nodePaths };
|
||||
return executeSceneOp(runner, 'delete_nodes', params, v.projectPath, 'Failed to delete nodes', ['Check if the node paths are correct'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleSetNodeProperties(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.updates || !Array.isArray(args.updates)) {
|
||||
return createErrorResponse('updates array is required', [
|
||||
'Provide an array of { nodePath, property, value }',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
updates: args.updates,
|
||||
abortOnError: args.abortOnError ?? false,
|
||||
};
|
||||
return executeSceneOp(runner, 'set_node_properties', params, v.projectPath, 'Failed to set node properties', ['Check node paths and property names'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleGetNodeProperties(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodes || !Array.isArray(args.nodes)) {
|
||||
return createErrorResponse('nodes array is required', [
|
||||
'Provide an array of { nodePath, changedOnly? }',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodes: args.nodes };
|
||||
return executeSceneOp(runner, 'get_node_properties', params, v.projectPath, 'Failed to get node properties', ['Check node paths']);
|
||||
}
|
||||
export async function handleAttachScript(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', [
|
||||
'Provide the node path (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
if (!args.scriptPath || !validateSubPath(v.projectPath, args.scriptPath)) {
|
||||
return createErrorResponse('Valid scriptPath is required', [
|
||||
'Provide a relative script path that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const scriptFullPath = join(v.projectPath, args.scriptPath);
|
||||
if (!existsSync(scriptFullPath)) {
|
||||
return createErrorResponse(`Script file does not exist: ${args.scriptPath}`, [
|
||||
'Create the script file first',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
nodePath: args.nodePath,
|
||||
scriptPath: args.scriptPath,
|
||||
};
|
||||
return executeSceneOp(runner, 'attach_script', params, v.projectPath, 'Failed to attach script', ['Ensure the script is valid for this node type'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleGetSceneTree(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (args.parentPath && !validateNodePath(args.parentPath)) {
|
||||
return createErrorResponse('Invalid parentPath', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath };
|
||||
if (args.parentPath)
|
||||
params.parentPath = args.parentPath;
|
||||
if (typeof args.maxDepth === 'number')
|
||||
params.maxDepth = args.maxDepth;
|
||||
return executeSceneOp(runner, 'get_scene_tree', params, v.projectPath, 'Failed to get scene tree', ['Ensure the scene is valid']);
|
||||
}
|
||||
export async function handleDuplicateNode(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', [
|
||||
'Provide the node path to duplicate',
|
||||
]);
|
||||
}
|
||||
if (args.targetParentPath && !validateNodePath(args.targetParentPath)) {
|
||||
return createErrorResponse('Invalid targetParentPath', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodePath: args.nodePath };
|
||||
if (args.newName)
|
||||
params.newName = args.newName;
|
||||
if (args.targetParentPath)
|
||||
params.targetParentPath = args.targetParentPath;
|
||||
return executeSceneOp(runner, 'duplicate_node', params, v.projectPath, 'Failed to duplicate node', ['Check if the node path and target parent path are correct'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleGetNodeSignals(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', [
|
||||
'Provide the node path (e.g. "root/Button")',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath, nodePath: args.nodePath };
|
||||
return executeSceneOp(runner, 'get_node_signals', params, v.projectPath, 'Failed to get node signals', ['Check if the node path is correct'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
function validateSignalArgs(args) {
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', ['Provide the source node path']);
|
||||
}
|
||||
if (!args.signal || !args.targetNodePath || !args.method) {
|
||||
return createErrorResponse('signal, targetNodePath, and method are required', [
|
||||
'Provide all three parameters',
|
||||
]);
|
||||
}
|
||||
if (!validateNodePath(args.targetNodePath)) {
|
||||
return createErrorResponse('Invalid targetNodePath', [
|
||||
'Provide a scene-tree path without ".." (e.g. "root/Player")',
|
||||
]);
|
||||
}
|
||||
return {
|
||||
projectPath: v.projectPath,
|
||||
scenePath: v.scenePath,
|
||||
nodePath: args.nodePath,
|
||||
signal: args.signal,
|
||||
targetNodePath: args.targetNodePath,
|
||||
method: args.method,
|
||||
};
|
||||
}
|
||||
export async function handleConnectSignal(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSignalArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const params = {
|
||||
scenePath: v.scenePath,
|
||||
nodePath: v.nodePath,
|
||||
signal: v.signal,
|
||||
targetNodePath: v.targetNodePath,
|
||||
method: v.method,
|
||||
};
|
||||
return executeSceneOp(runner, 'connect_signal', params, v.projectPath, 'Failed to connect signal', ['Ensure the signal exists on the source node and the method exists on the target node']);
|
||||
}
|
||||
export async function handleDisconnectSignal(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSignalArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const params = {
|
||||
scenePath: v.scenePath,
|
||||
nodePath: v.nodePath,
|
||||
signal: v.signal,
|
||||
targetNodePath: v.targetNodePath,
|
||||
method: v.method,
|
||||
};
|
||||
return executeSceneOp(runner, 'disconnect_signal', params, v.projectPath, 'Failed to disconnect signal', ['Ensure the signal connection exists before trying to disconnect it']);
|
||||
}
|
||||
//# sourceMappingURL=node-tools.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+65
@@ -0,0 +1,65 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const projectToolDefinitions: ToolDefinition[];
|
||||
export declare function handleListProjects(args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleGetProjectInfo(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleGetProjectFiles(args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleSearchProject(args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetSceneDependencies(args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetProjectSettings(args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
//# sourceMappingURL=project-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"project-tools.d.ts","sourceRoot":"","sources":["../../src/tools/project-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAoB7F,eAAO,MAAM,sBAAsB,EAAE,cAAc,EAwJlD,CAAC;AAsPF,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;GAkC7D;AAED,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GAkDpF;AAED,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;GAiBhE;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,eAAe;;;;;;GA4B9D;AAED,wBAAsB,0BAA0B,CAAC,IAAI,EAAE,eAAe;;;;;;GAoDrE;AAED,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,eAAe;;;;;;;;;;;GAkBnE"}
|
||||
Vendored
+544
@@ -0,0 +1,544 @@
|
||||
import { join, basename } from 'path';
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { normalizeParameters, validatePath, validateSubPath, validateProjectArgs, createErrorResponse, createStructuredResponse, getErrorMessage, projectGodotPath, } from '../utils/godot-runner.js';
|
||||
import { logDebug } from '../utils/logger.js';
|
||||
function fileExtension(name) {
|
||||
const dotIdx = name.lastIndexOf('.');
|
||||
return dotIdx >= 0 ? name.slice(dotIdx + 1).toLowerCase() : '';
|
||||
}
|
||||
// --- Tool definitions ---
|
||||
export const projectToolDefinitions = [
|
||||
{
|
||||
name: 'list_projects',
|
||||
description: 'Find Godot projects under a directory by locating project.godot files. Use to discover available projects when the user has not specified one; for inspecting a known project, use get_project_info. recursive:true descends into subdirectories (skipping hidden ones); default false checks only the directory itself and its immediate children. Returns: [{ path, name }], empty array on no matches.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
directory: {
|
||||
type: 'string',
|
||||
description: 'Directory to search for Godot projects',
|
||||
},
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to search recursively (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['directory'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_project_info',
|
||||
description: 'Get metadata about a Godot project: name, path, Godot version, and a structure summary (counts of scenes/scripts/assets/other). Omit projectPath to get just the Godot version (useful for capability checks). Returns: { name, path, godotVersion, structure } or { godotVersion } when projectPath is omitted. Errors if projectPath is set but lacks project.godot.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory (optional — omit to get Godot version only)',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_project_files',
|
||||
description: 'Return a recursive file tree of a Godot project. Use to discover project structure when paths are unknown. Pass extensions to filter (e.g. ["gd","tscn"]); maxDepth caps recursion (-1 unlimited). Skips hidden (dot-prefixed) entries and the .mcp directory. Returns: { name, type, path, extension?, children? } (nested tree).',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Maximum recursion depth. -1 means unlimited (default: -1)',
|
||||
},
|
||||
extensions: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Filter to only these file extensions (e.g. ["gd", "tscn"]). Omit to include all.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_project',
|
||||
description: 'Plain-text (substring) search across project files. Use to find references, callers, or signatures across the codebase. Default fileTypes is ["gd","tscn","cs","gdshader"]; caseSensitive default false; maxResults default 100. Skips hidden entries and the .mcp directory. Returns: matches[] (project-relative file, 1-indexed lineNumber, line text) and truncated:true when maxResults was hit — consider raising it.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
pattern: { type: 'string', description: 'Plain-text string to search for' },
|
||||
fileTypes: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'File extensions to search (default: ["gd", "tscn", "cs", "gdshader"])',
|
||||
},
|
||||
caseSensitive: { type: 'boolean', description: 'Case-sensitive search (default: false)' },
|
||||
maxResults: { type: 'number', description: 'Maximum matches to return (default: 100)' },
|
||||
},
|
||||
required: ['projectPath', 'pattern'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
matches: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
lineNumber: { type: 'number' },
|
||||
line: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
truncated: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_scene_dependencies',
|
||||
description: 'Parse a .tscn file for ext_resource references (scripts, textures, subscenes). Use to inspect what a scene depends on before refactoring or moving files. Returns: the queried scene path and dependencies[] from ext_resource refs (path, type, optional uid). Errors if scene file does not exist.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Path to the .tscn file relative to the project root (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scene: { type: 'string' },
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
uid: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_project_settings',
|
||||
description: 'Parse project.godot into structured JSON. Use to inspect configured display, input, rendering, etc. settings without launching Godot. Pass section to filter to one INI section (e.g. "display", "application"). Returns: { settings: { [section]: { [key]: value } } } or { settings: { [key]: value } } when section is given. Complex Godot types are returned as raw strings; keys outside any section appear under __global__.',
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
section: {
|
||||
type: 'string',
|
||||
description: 'Filter to a specific INI section (e.g. "display", "application"). Omit for all sections.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Helpers ---
|
||||
const PROJECT_SCAN_BLACKLIST = new Set(['.git', '.godot', '.mcp', 'node_modules', '.svn', '.hg']);
|
||||
function findGodotProjects(directory, recursive) {
|
||||
const projects = [];
|
||||
try {
|
||||
const projectFile = projectGodotPath(directory);
|
||||
if (existsSync(projectFile)) {
|
||||
projects.push({
|
||||
path: directory,
|
||||
name: basename(directory),
|
||||
});
|
||||
}
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || PROJECT_SCAN_BLACKLIST.has(entry.name))
|
||||
continue;
|
||||
const subdir = join(directory, entry.name);
|
||||
if (existsSync(projectGodotPath(subdir))) {
|
||||
projects.push({ path: subdir, name: entry.name });
|
||||
}
|
||||
else if (recursive) {
|
||||
projects.push(...findGodotProjects(subdir, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Error searching directory ${directory}: ${error}`);
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
function getProjectStructure(projectPath) {
|
||||
const structure = {
|
||||
scenes: 0,
|
||||
scripts: 0,
|
||||
assets: 0,
|
||||
other: 0,
|
||||
};
|
||||
const scanDirectory = (currentPath) => {
|
||||
try {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(currentPath, entry.name);
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
scanDirectory(entryPath);
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
const ext = fileExtension(entry.name);
|
||||
if (ext === 'tscn') {
|
||||
structure.scenes++;
|
||||
}
|
||||
else if (ext === 'gd' || ext === 'gdscript' || ext === 'cs') {
|
||||
structure.scripts++;
|
||||
}
|
||||
else if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'ttf', 'wav', 'mp3', 'ogg'].includes(ext || '')) {
|
||||
structure.assets++;
|
||||
}
|
||||
else {
|
||||
structure.other++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Error scanning directory ${currentPath}: ${error}`);
|
||||
}
|
||||
};
|
||||
scanDirectory(projectPath);
|
||||
return structure;
|
||||
}
|
||||
function buildFilesystemTree(currentPath, relativePath, maxDepth, currentDepth, extensions) {
|
||||
const name = basename(currentPath);
|
||||
const node = { name, type: 'dir', path: relativePath || '.' };
|
||||
if (maxDepth !== -1 && currentDepth >= maxDepth) {
|
||||
node.children = [];
|
||||
return node;
|
||||
}
|
||||
const children = [];
|
||||
try {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.'))
|
||||
continue;
|
||||
const childRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
children.push(buildFilesystemTree(join(currentPath, entry.name), childRelPath, maxDepth, currentDepth + 1, extensions));
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
const ext = fileExtension(entry.name);
|
||||
if (extensions && !extensions.includes(ext))
|
||||
continue;
|
||||
children.push({ name: entry.name, type: 'file', path: childRelPath, extension: ext });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`buildFilesystemTree error at ${currentPath}: ${err}`);
|
||||
}
|
||||
node.children = children;
|
||||
return node;
|
||||
}
|
||||
function searchInFiles(rootPath, pattern, fileTypes, caseSensitive, maxResults) {
|
||||
const matches = [];
|
||||
let truncated = false;
|
||||
const searchDir = (currentPath, relBase) => {
|
||||
if (truncated)
|
||||
return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
}
|
||||
catch (err) {
|
||||
logDebug(`searchInFiles readdir error at ${currentPath}: ${err}`);
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (truncated)
|
||||
return;
|
||||
if (entry.name.startsWith('.'))
|
||||
continue;
|
||||
const childRelPath = relBase ? `${relBase}/${entry.name}` : entry.name;
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
searchDir(fullPath, childRelPath);
|
||||
}
|
||||
else if (entry.isFile()) {
|
||||
const ext = fileExtension(entry.name);
|
||||
if (!fileTypes.includes(ext))
|
||||
continue;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(fullPath, 'utf8');
|
||||
}
|
||||
catch {
|
||||
continue;
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const needle = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const haystack = caseSensitive ? lines[i] : lines[i].toLowerCase();
|
||||
if (haystack.includes(needle)) {
|
||||
matches.push({ file: childRelPath, lineNumber: i + 1, line: lines[i] });
|
||||
if (matches.length >= maxResults) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
searchDir(rootPath, '');
|
||||
return { matches, truncated };
|
||||
}
|
||||
function parseProjectSettings(projectFilePath) {
|
||||
const content = readFileSync(projectFilePath, 'utf8');
|
||||
const result = {};
|
||||
let currentSection = '__global__';
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line === '' || line.startsWith(';') || line.startsWith('#'))
|
||||
continue;
|
||||
if (line.startsWith('config_version'))
|
||||
continue; // header line
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
currentSection = line.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
const eqIdx = line.indexOf('=');
|
||||
if (eqIdx === -1)
|
||||
continue;
|
||||
const key = line.slice(0, eqIdx).trim();
|
||||
const rawVal = line.slice(eqIdx + 1).trim();
|
||||
let value;
|
||||
if (rawVal.startsWith('"') && rawVal.endsWith('"')) {
|
||||
value = rawVal.slice(1, -1);
|
||||
}
|
||||
else if (rawVal === 'true') {
|
||||
value = true;
|
||||
}
|
||||
else if (rawVal === 'false') {
|
||||
value = false;
|
||||
}
|
||||
else {
|
||||
const num = Number(rawVal);
|
||||
value = isNaN(num) ? rawVal : num;
|
||||
}
|
||||
if (!result[currentSection])
|
||||
result[currentSection] = {};
|
||||
result[currentSection][key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// --- Handlers ---
|
||||
export async function handleListProjects(args) {
|
||||
args = normalizeParameters(args);
|
||||
if (!args.directory) {
|
||||
return createErrorResponse('Directory is required', [
|
||||
'Provide a valid directory path to search for Godot projects',
|
||||
]);
|
||||
}
|
||||
if (!validatePath(args.directory)) {
|
||||
return createErrorResponse('Invalid directory path', [
|
||||
'Provide a valid path without ".." or other potentially unsafe characters',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
if (!existsSync(args.directory)) {
|
||||
return createErrorResponse(`Directory does not exist: ${args.directory}`, [
|
||||
'Provide a valid directory path that exists on the system',
|
||||
]);
|
||||
}
|
||||
const recursive = args.recursive === true;
|
||||
const projects = findGodotProjects(args.directory, recursive);
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(projects) }],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to list projects: ${getErrorMessage(error)}`, [
|
||||
'Ensure the directory exists and is accessible',
|
||||
'Check if you have permission to read the directory',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetProjectInfo(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
try {
|
||||
const version = await runner.getVersion();
|
||||
// If no project path, return just the Godot version
|
||||
if (!args.projectPath) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ godotVersion: version }) }],
|
||||
};
|
||||
}
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const projectStructure = getProjectStructure(v.projectPath);
|
||||
let projectName = basename(v.projectPath);
|
||||
try {
|
||||
const projectFileContent = readFileSync(projectFile, 'utf8');
|
||||
const configNameMatch = projectFileContent.match(/config\/name="([^"]+)"/);
|
||||
if (configNameMatch && configNameMatch[1]) {
|
||||
projectName = configNameMatch[1];
|
||||
logDebug(`Found project name in config: ${projectName}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logDebug(`Error reading project file: ${error}`);
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
name: projectName,
|
||||
path: v.projectPath,
|
||||
godotVersion: version,
|
||||
structure: projectStructure,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get project info: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetProjectFiles(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const maxDepth = typeof args.maxDepth === 'number' ? args.maxDepth : -1;
|
||||
const extensions = Array.isArray(args.extensions)
|
||||
? args.extensions.map((e) => e.toLowerCase().replace(/^\./, ''))
|
||||
: null;
|
||||
const tree = buildFilesystemTree(v.projectPath, '', maxDepth, 0, extensions);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(tree) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get project files: ${getErrorMessage(error)}`, [
|
||||
'Check if the project directory is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleSearchProject(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.pattern || typeof args.pattern !== 'string') {
|
||||
return createErrorResponse('pattern is required', ['Provide a plain-text search string']);
|
||||
}
|
||||
try {
|
||||
const fileTypes = Array.isArray(args.fileTypes)
|
||||
? args.fileTypes.map((e) => e.toLowerCase().replace(/^\./, ''))
|
||||
: ['gd', 'tscn', 'cs', 'gdshader'];
|
||||
const caseSensitive = args.caseSensitive === true;
|
||||
const maxResults = typeof args.maxResults === 'number' ? args.maxResults : 100;
|
||||
const result = searchInFiles(v.projectPath, args.pattern, fileTypes, caseSensitive, maxResults);
|
||||
return createStructuredResponse(result);
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to search project: ${getErrorMessage(error)}`, [
|
||||
'Check if the project directory is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetSceneDependencies(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.scenePath || typeof args.scenePath !== 'string') {
|
||||
return createErrorResponse('scenePath is required', [
|
||||
'Provide a path relative to the project root, e.g. "scenes/main.tscn"',
|
||||
]);
|
||||
}
|
||||
if (!validateSubPath(v.projectPath, args.scenePath)) {
|
||||
return createErrorResponse('Invalid scenePath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const sceneFullPath = join(v.projectPath, args.scenePath);
|
||||
if (!existsSync(sceneFullPath)) {
|
||||
return createErrorResponse(`Scene file not found: ${args.scenePath}`, [
|
||||
'Verify the path is relative to the project root',
|
||||
'Use get_project_files to list available .tscn files',
|
||||
]);
|
||||
}
|
||||
const sceneContent = readFileSync(sceneFullPath, 'utf8');
|
||||
const dependencies = [];
|
||||
const extResourcePattern = /^\[ext_resource([^\]]*)\]/gm;
|
||||
let match;
|
||||
while ((match = extResourcePattern.exec(sceneContent)) !== null) {
|
||||
const attrs = match[1];
|
||||
const typeMatch = attrs.match(/\btype="([^"]*)"/);
|
||||
const pathMatch = attrs.match(/\bpath="([^"]*)"/);
|
||||
const uidMatch = attrs.match(/\buid="([^"]*)"/);
|
||||
if (pathMatch) {
|
||||
const depPath = pathMatch[1].replace(/^res:\/\//, '');
|
||||
const dep = {
|
||||
path: depPath,
|
||||
type: typeMatch ? typeMatch[1] : 'Unknown',
|
||||
};
|
||||
if (uidMatch)
|
||||
dep.uid = uidMatch[1];
|
||||
dependencies.push(dep);
|
||||
}
|
||||
}
|
||||
return createStructuredResponse({
|
||||
scene: args.scenePath,
|
||||
dependencies,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get scene dependencies: ${getErrorMessage(error)}`, [
|
||||
'Check if the scene file is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
export async function handleGetProjectSettings(args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
try {
|
||||
const projectFile = projectGodotPath(v.projectPath);
|
||||
const allSettings = parseProjectSettings(projectFile);
|
||||
if (args.section && typeof args.section === 'string') {
|
||||
const sectionData = allSettings[args.section] ?? {};
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ settings: sectionData }) }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ settings: allSettings }) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Failed to get project settings: ${getErrorMessage(error)}`, [
|
||||
'Check if project.godot is accessible',
|
||||
]);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=project-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+88
@@ -0,0 +1,88 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const runtimeToolDefinitions: ToolDefinition[];
|
||||
export declare function handleLaunchEditor(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleRunProject(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleAttachProject(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
export declare function handleDetachProject(runner: GodotRunner): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetDebugOutput(runner: GodotRunner, args?: OperationParams): import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
};
|
||||
export declare function handleStopProject(runner: GodotRunner): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleTakeScreenshot(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleSimulateInput(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleGetUiElements(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleRunScript(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=runtime-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"runtime-tools.d.ts","sourceRoot":"","sources":["../../src/tools/runtime-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAqC7F,eAAO,MAAM,sBAAsB,EAAE,cAAc,EA2XlD,CAAC;AAiCF,wBAAsB,kBAAkB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GAsClF;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GA+HhF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GA0EnF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW;;;;;;GAc5D;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,GAAE,eAAoB;;;;;;EAgDnF;AAED,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW;;;;;;GAoB1D;AAoBD,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAyIpF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAiEnF;AAED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA4CnF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAkG/E"}
|
||||
Vendored
+948
@@ -0,0 +1,948 @@
|
||||
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
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const sceneToolDefinitions: ToolDefinition[];
|
||||
export declare function handleCreateScene(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleAddNode(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleLoadSprite(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleSaveScene(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleExportMeshLibrary(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
export declare function handleBatchSceneOperations(runner: GodotRunner, args: OperationParams): Promise<import("../utils/godot-runner.js").ToolResponse | {
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
}>;
|
||||
//# sourceMappingURL=scene-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"scene-tools.d.ts","sourceRoot":"","sources":["../../src/tools/scene-tools.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAW7F,eAAO,MAAM,oBAAoB,EAAE,cAAc,EAyNhD,CAAC;AAIF,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAmBjF;AAED,wBAAsB,aAAa,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAsC7E;AAED,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA4BhF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAgB/E;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GA0BvF;AAED,wBAAsB,0BAA0B,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;GAyB1F"}
|
||||
Vendored
+342
@@ -0,0 +1,342 @@
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { normalizeParameters, validateSubPath, validateNodePath, createErrorResponse, validateProjectArgs, validateSceneArgs, } from '../utils/godot-runner.js';
|
||||
import { executeSceneOp } from '../utils/handler-helpers.js';
|
||||
export const sceneToolDefinitions = [
|
||||
{
|
||||
name: 'create_scene',
|
||||
description: 'Create a new Godot scene file with a single root node. Writes a fresh .tscn at scenePath. Use when starting a new scene from scratch; for adding nodes to an existing scene, use add_node. rootNodeType defaults to Node2D — pass "Node3D" for 3D scenes or "Control" for UI. Saves automatically. Overwrites silently if the file already exists. Returns: success and the scenePath that was written.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Scene file path relative to the project (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
rootNodeType: { type: 'string', description: 'Root node type (default: Node2D)' },
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
scenePath: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add_node',
|
||||
description: 'Add a node to a Godot scene. Saves automatically. Common spatial properties (position, position3d, rotation, scale, visible, modulate) can be set as top-level params; for any other property, pass it under properties. Vector2/Vector3/Color values auto-convert from {x,y}/{x,y,z}/{r,g,b,a}. parentNodePath defaults to the scene root. Returns a plain-text confirmation message naming the new node and type. Errors if nodeType is not a registered Godot class or parentNodePath does not exist.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Godot node class to instantiate (e.g. "Sprite2D", "CollisionShape2D", "Label")',
|
||||
},
|
||||
nodeName: {
|
||||
type: 'string',
|
||||
description: 'Name for the new node as it appears in the scene tree',
|
||||
},
|
||||
parentNodePath: {
|
||||
type: 'string',
|
||||
description: 'Parent node path from scene root (e.g. "root/Player"). Defaults to the root node.',
|
||||
},
|
||||
position: {
|
||||
type: 'object',
|
||||
description: 'Vector2 position (e.g. {"x": 100, "y": 200})',
|
||||
properties: { x: { type: 'number' }, y: { type: 'number' } },
|
||||
},
|
||||
position3d: {
|
||||
type: 'object',
|
||||
description: 'Vector3 position for 3D nodes (e.g. {"x": 0, "y": 1, "z": 0})',
|
||||
properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } },
|
||||
},
|
||||
rotation: { type: 'number', description: 'Rotation in radians' },
|
||||
scale: {
|
||||
type: 'object',
|
||||
description: 'Vector2 scale (e.g. {"x": 2, "y": 2})',
|
||||
properties: { x: { type: 'number' }, y: { type: 'number' } },
|
||||
},
|
||||
visible: { type: 'boolean', description: 'Whether the node is visible' },
|
||||
modulate: {
|
||||
type: 'object',
|
||||
description: 'Color modulation (e.g. {"r": 1, "g": 0, "b": 0, "a": 1})',
|
||||
properties: {
|
||||
r: { type: 'number' },
|
||||
g: { type: 'number' },
|
||||
b: { type: 'number' },
|
||||
a: { type: 'number' },
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'Additional property values as a JSON object. Top-level params (position, rotation, etc.) take precedence over keys in this dict.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodeType', 'nodeName'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'load_sprite',
|
||||
description: 'Set the texture on an existing Sprite2D, Sprite3D, or TextureRect node. Use this when the node already exists; for new nodes, pass texture via add_node properties. Saves automatically. texturePath must be a real file under projectPath. Returns a plain-text confirmation message naming the loaded texture. Errors if the node is not one of those three classes, or the texture file does not exist.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
nodePath: {
|
||||
type: 'string',
|
||||
description: 'Path to the target node from scene root (e.g. "root/Player/Sprite2D")',
|
||||
},
|
||||
texturePath: {
|
||||
type: 'string',
|
||||
description: 'Path to the texture file relative to the project (e.g. "assets/player.png")',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'nodePath', 'texturePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'save_scene',
|
||||
description: 'Re-pack and save a scene, optionally to a different path (save-as). Most mutations (add_node, set_node_properties, delete_nodes, etc.) auto-save — only use this for save-as via newPath, or to re-canonicalize a hand-edited .tscn. Overwrites silently. Returns a plain-text confirmation naming the save path. Errors if the scene file does not exist.',
|
||||
annotations: { idempotentHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
newPath: {
|
||||
type: 'string',
|
||||
description: 'Save to a different path (relative to project) instead of overwriting the original',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'export_mesh_library',
|
||||
description: 'Export a scene of MeshInstance3D nodes as a MeshLibrary .res file for use in GridMap. Use this when authoring tile palettes for grid-based 3D levels; ignore for 2D or general scene work. The source scene must contain MeshInstance3D children. Pass meshItemNames to export a subset, or omit to export all. Saves the .res to outputPath, overwriting silently. Returns a plain-text confirmation with the exported item count. Errors if the scene contains no valid meshes.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
scenePath: { type: 'string', description: 'Scene file path relative to the project' },
|
||||
outputPath: {
|
||||
type: 'string',
|
||||
description: 'Output path for the MeshLibrary .res file (relative to project)',
|
||||
},
|
||||
meshItemNames: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Names of specific mesh items to export. Omit to export all.',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'scenePath', 'outputPath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'batch_scene_operations',
|
||||
description: 'Use this instead of chaining add_node / load_sprite / save_scene calls when you have multiple mutations on the same or related scenes — runs in one Godot process (~3s startup avoided per call) and shares an in-memory scene cache, saving once at the end. Each item picks its sub-operation (add_node, load_sprite, save) and supplies its own params; abortOnError stops on first failure (default false continues). Returns: results[] in input order, each tagged with operation and scenePath plus success or error.',
|
||||
annotations: { destructiveHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: { type: 'string', description: 'Path to the Godot project directory' },
|
||||
operations: {
|
||||
type: 'array',
|
||||
description: 'Ordered list of scene operations. Each item has its own operation and scenePath.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
enum: ['add_node', 'load_sprite', 'save'],
|
||||
description: 'The sub-operation to perform',
|
||||
},
|
||||
scenePath: { type: 'string', description: 'Scene file path for this operation' },
|
||||
nodeType: { type: 'string', description: '[add_node] Node class to instantiate' },
|
||||
nodeName: { type: 'string', description: '[add_node] Name for the new node' },
|
||||
parentNodePath: {
|
||||
type: 'string',
|
||||
description: '[add_node] Parent node path (defaults to root)',
|
||||
},
|
||||
properties: { type: 'object', description: '[add_node] Initial property values' },
|
||||
nodePath: { type: 'string', description: '[load_sprite] Target node path' },
|
||||
texturePath: {
|
||||
type: 'string',
|
||||
description: '[load_sprite] Texture file path relative to project',
|
||||
},
|
||||
newPath: {
|
||||
type: 'string',
|
||||
description: '[save] Save to a different path instead of overwriting',
|
||||
},
|
||||
},
|
||||
required: ['operation'],
|
||||
},
|
||||
},
|
||||
abortOnError: {
|
||||
type: 'boolean',
|
||||
description: 'Stop processing on first error (default: false)',
|
||||
},
|
||||
},
|
||||
required: ['projectPath', 'operations'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: { type: 'string' },
|
||||
scenePath: { type: 'string' },
|
||||
success: { type: 'boolean' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
// --- Handlers ---
|
||||
export async function handleCreateScene(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args, { sceneRequired: false });
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
rootNodeType: args.rootNodeType || 'Node2D',
|
||||
};
|
||||
return executeSceneOp(runner, 'create_scene', params, v.projectPath, 'Failed to create scene', ['Check if the root node type is valid'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
export async function handleAddNode(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodeType || !args.nodeName) {
|
||||
return createErrorResponse('nodeType and nodeName are required', [
|
||||
'Provide both nodeType and nodeName',
|
||||
]);
|
||||
}
|
||||
// Merge promoted top-level params into properties dict
|
||||
const promotedKeys = [
|
||||
'position',
|
||||
'position3d',
|
||||
'rotation',
|
||||
'scale',
|
||||
'visible',
|
||||
'modulate',
|
||||
];
|
||||
const mergedProps = args.properties || {};
|
||||
for (const key of promotedKeys) {
|
||||
if (args[key] !== undefined) {
|
||||
mergedProps[key] = args[key];
|
||||
}
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
nodeType: args.nodeType,
|
||||
nodeName: args.nodeName,
|
||||
};
|
||||
if (args.parentNodePath)
|
||||
params.parentNodePath = args.parentNodePath;
|
||||
if (Object.keys(mergedProps).length > 0)
|
||||
params.properties = mergedProps;
|
||||
return executeSceneOp(runner, 'add_node', params, v.projectPath, 'Failed to add node', [
|
||||
'Check if the node type is valid',
|
||||
'Ensure the parent node path exists',
|
||||
]);
|
||||
}
|
||||
export async function handleLoadSprite(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.nodePath || !validateNodePath(args.nodePath)) {
|
||||
return createErrorResponse('Valid nodePath is required', ['Provide the target node path']);
|
||||
}
|
||||
if (!args.texturePath || !validateSubPath(v.projectPath, args.texturePath)) {
|
||||
return createErrorResponse('Valid texturePath is required', [
|
||||
'Provide a relative texture path that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const textureFullPath = join(v.projectPath, args.texturePath);
|
||||
if (!existsSync(textureFullPath)) {
|
||||
return createErrorResponse(`Texture file does not exist: ${args.texturePath}`, [
|
||||
'Ensure the texture path is correct',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
nodePath: args.nodePath,
|
||||
texturePath: args.texturePath,
|
||||
};
|
||||
return executeSceneOp(runner, 'load_sprite', params, v.projectPath, 'Failed to load sprite', [
|
||||
'Check if the node is a Sprite2D, Sprite3D, or TextureRect',
|
||||
]);
|
||||
}
|
||||
export async function handleSaveScene(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (args.newPath && !validateSubPath(v.projectPath, args.newPath)) {
|
||||
return createErrorResponse('Invalid newPath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const params = { scenePath: args.scenePath };
|
||||
if (args.newPath)
|
||||
params.newPath = args.newPath;
|
||||
return executeSceneOp(runner, 'save_scene', params, v.projectPath, 'Failed to save scene', [
|
||||
'Check if the scene file is valid',
|
||||
]);
|
||||
}
|
||||
export async function handleExportMeshLibrary(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateSceneArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.outputPath || !validateSubPath(v.projectPath, args.outputPath)) {
|
||||
return createErrorResponse('Valid outputPath is required', [
|
||||
'Provide an output path for the .res file that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
scenePath: args.scenePath,
|
||||
outputPath: args.outputPath,
|
||||
};
|
||||
if (args.meshItemNames && Array.isArray(args.meshItemNames)) {
|
||||
params.meshItemNames = args.meshItemNames;
|
||||
}
|
||||
return executeSceneOp(runner, 'export_mesh_library', params, v.projectPath, 'Failed to export mesh library', ['Check if the scene contains valid 3D meshes']);
|
||||
}
|
||||
export async function handleBatchSceneOperations(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const v = validateProjectArgs(args);
|
||||
if ('isError' in v)
|
||||
return v;
|
||||
if (!args.operations || !Array.isArray(args.operations)) {
|
||||
return createErrorResponse('operations array is required', [
|
||||
'Provide an operations array with at least one item',
|
||||
]);
|
||||
}
|
||||
const params = {
|
||||
operations: args.operations,
|
||||
abortOnError: args.abortOnError ?? false,
|
||||
};
|
||||
return executeSceneOp(runner, 'batch_scene_operations', params, v.projectPath, 'Batch scene operations failed', ['Check that all scene paths exist', 'Ensure node types are valid'], undefined, { parseStdoutAsJson: true });
|
||||
}
|
||||
//# sourceMappingURL=scene-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+15
@@ -0,0 +1,15 @@
|
||||
import type { GodotRunner, OperationParams, ToolDefinition } from '../utils/godot-runner.js';
|
||||
export declare const validateToolDefinitions: ToolDefinition[];
|
||||
export declare function handleValidate(runner: GodotRunner, args: OperationParams): Promise<{
|
||||
content: Array<{
|
||||
type: "text";
|
||||
text: string;
|
||||
}>;
|
||||
isError: boolean;
|
||||
} | {
|
||||
content: {
|
||||
type: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>;
|
||||
//# sourceMappingURL=validate-tools.d.ts.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"validate-tools.d.ts","sourceRoot":"","sources":["../../src/tools/validate-tools.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAU7F,eAAO,MAAM,uBAAuB,EAAE,cAAc,EAmDnD,CAAC;AAmIF,wBAAsB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,eAAe;;;;;;;;;;;GAqP9E"}
|
||||
Vendored
+375
@@ -0,0 +1,375 @@
|
||||
import { join } from 'path';
|
||||
import { existsSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { normalizeParameters, validateSubPath, validateProjectArgs, createErrorResponse, extractGdError, getErrorMessage, } from '../utils/godot-runner.js';
|
||||
export const validateToolDefinitions = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: "Validate GDScript syntax or scene file integrity using headless Godot. Use before attach_script or run_script to catch parse errors early. Single-target: provide exactly one of scriptPath, source, or scenePath. Batch: provide a targets array — runs all in one Godot process. Returns { valid, errors: [{ line?, message }] } for single, or { results: [{ target, valid, errors }] } for batch. Line numbers appear when Godot's stderr includes them (not always). Returns valid:false on any parse error; never throws.",
|
||||
annotations: { readOnlyHint: true },
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectPath: {
|
||||
type: 'string',
|
||||
description: 'Path to the Godot project directory',
|
||||
},
|
||||
scriptPath: {
|
||||
type: 'string',
|
||||
description: '[single] Path to a .gd file relative to the project to validate (e.g. "scripts/player.gd")',
|
||||
},
|
||||
source: {
|
||||
type: 'string',
|
||||
description: '[single] Inline GDScript source code to validate. Written to a temporary file and validated against the project.',
|
||||
},
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: '[single] Path to a .tscn scene file relative to the project to validate (e.g. "scenes/main.tscn")',
|
||||
},
|
||||
targets: {
|
||||
type: 'array',
|
||||
description: '[batch] Array of targets to validate in a single Godot process. Each item must have exactly one of: scriptPath, source, or scenePath.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
scriptPath: {
|
||||
type: 'string',
|
||||
description: 'Path to a .gd file relative to the project',
|
||||
},
|
||||
source: { type: 'string', description: 'Inline GDScript source code' },
|
||||
scenePath: {
|
||||
type: 'string',
|
||||
description: 'Path to a .tscn file relative to the project',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['projectPath'],
|
||||
},
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Core Godot stderr parser. Returns a flat list of error entries, each with an
|
||||
* optional line number and optional res:// file path (from the "at:" line).
|
||||
*/
|
||||
function parseGodotErrorEntries(stderr) {
|
||||
const entries = [];
|
||||
if (!stderr)
|
||||
return entries;
|
||||
const lines = stderr.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Pattern: "SCRIPT ERROR: Parse Error: MESSAGE" or "ERROR: MESSAGE"
|
||||
// followed by " at: res://...:LINE" or " at: ...:LINE"
|
||||
const scriptErrorMatch = line.match(/SCRIPT ERROR:\s*(?:Parse Error:\s*)?(.+)/);
|
||||
const errorMatch = !scriptErrorMatch ? line.match(/^ERROR:\s*(.+)/) : null;
|
||||
const match = scriptErrorMatch || errorMatch;
|
||||
if (match) {
|
||||
const message = match[1].trim();
|
||||
let lineNum;
|
||||
let filePath;
|
||||
if (i + 1 < lines.length) {
|
||||
// Try res:// path first (captures file + line). Tolerates an optional
|
||||
// "<method> (" prefix before res:// so we catch both
|
||||
// " at: res://foo.gd:3"
|
||||
// and
|
||||
// " at: GDScript::reload (res://foo.gd:3)"
|
||||
// Real Godot 4.5 stderr uses the parenthesized form.
|
||||
const resAtMatch = lines[i + 1].match(/\s*at:\s*(?:[^()\n]*\()?(res:\/\/[^):"\s]+):(\d+)/);
|
||||
if (resAtMatch) {
|
||||
filePath = resAtMatch[1];
|
||||
lineNum = parseInt(resAtMatch[2], 10);
|
||||
i++;
|
||||
}
|
||||
else {
|
||||
// Fall back to loose match (line only, e.g. native code "at:" lines)
|
||||
const looseAtMatch = lines[i + 1].match(/\s*at:\s*.+:(\d+)/);
|
||||
if (looseAtMatch) {
|
||||
lineNum = parseInt(looseAtMatch[1], 10);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parse-error entries reference synthetic gdscript:// URIs in their `at:` line
|
||||
// rather than a res:// path. Peek forward up to 10 lines for the secondary
|
||||
// "Failed to load script/resource: \"res://...\"" message that names the file,
|
||||
// and adopt that path so batch error attribution can find it. The window is
|
||||
// intentionally wide enough to clear a full GDScript backtrace.
|
||||
if (!filePath && /Parse Error/i.test(line)) {
|
||||
const lookaheadLimit = Math.min(i + 11, lines.length);
|
||||
for (let j = i + 1; j < lookaheadLimit; j++) {
|
||||
const failMatch = lines[j].match(/Failed to load (?:script|resource):?\s*"?(res:\/\/[^":\s]+)/);
|
||||
if (failMatch) {
|
||||
filePath = failMatch[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.push({ message, line: lineNum, filePath });
|
||||
continue;
|
||||
}
|
||||
// Pattern: "Parse Error: MESSAGE at line LINE"
|
||||
const parseErrorMatch = line.match(/Parse Error:\s*(.+?)\s+at line\s+(\d+)/);
|
||||
if (parseErrorMatch) {
|
||||
entries.push({
|
||||
line: parseInt(parseErrorMatch[2], 10),
|
||||
message: parseErrorMatch[1].trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
function parseGodotErrors(stderr) {
|
||||
return parseGodotErrorEntries(stderr).map(({ message, line }) => ({ message, line }));
|
||||
}
|
||||
/**
|
||||
* Write inline GDScript source to a uniquely-named file under <projectPath>/.mcp/
|
||||
* for validation. Returns the project-relative path (e.g. ".mcp/validate_temp_xxx.gd")
|
||||
* that the runner consumes plus the absolute path the caller cleans up.
|
||||
*/
|
||||
function writeTempGdScript(projectPath, source, prefix) {
|
||||
const mcpDir = join(projectPath, '.mcp');
|
||||
mkdirSync(mcpDir, { recursive: true });
|
||||
const name = `${prefix}_${randomUUID()}.gd`;
|
||||
const absPath = join(mcpDir, name);
|
||||
writeFileSync(absPath, source, 'utf8');
|
||||
return { resPath: `.mcp/${name}`, absPath };
|
||||
}
|
||||
/**
|
||||
* Group Godot stderr errors by their res:// file path.
|
||||
* Used for batch validation where multiple files produce output in one stderr stream.
|
||||
*/
|
||||
function parseGodotErrorsByPath(stderr) {
|
||||
const result = new Map();
|
||||
for (const { message, line, filePath } of parseGodotErrorEntries(stderr)) {
|
||||
if (filePath) {
|
||||
if (!result.has(filePath))
|
||||
result.set(filePath, []);
|
||||
result.get(filePath).push({ line, message });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function handleValidate(runner, args) {
|
||||
args = normalizeParameters(args);
|
||||
const pv = validateProjectArgs(args);
|
||||
if ('isError' in pv)
|
||||
return pv;
|
||||
// Batch mode: targets array
|
||||
if (args.targets && Array.isArray(args.targets)) {
|
||||
const targets = args.targets;
|
||||
const tempFiles = [];
|
||||
try {
|
||||
const snakeTargets = [];
|
||||
const preErrors = new Map();
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const t = targets[i];
|
||||
if (t.source) {
|
||||
const { resPath, absPath } = writeTempGdScript(pv.projectPath, t.source, 'validate_batch');
|
||||
tempFiles.push(absPath);
|
||||
snakeTargets.push({ script_path: resPath });
|
||||
}
|
||||
else if (t.scriptPath) {
|
||||
if (!validateSubPath(pv.projectPath, t.scriptPath)) {
|
||||
preErrors.set(i, {
|
||||
target: t.scriptPath,
|
||||
errors: [
|
||||
{
|
||||
message: 'Invalid scriptPath: must be a relative path inside the project root, no ".."',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
else {
|
||||
snakeTargets.push({ script_path: t.scriptPath });
|
||||
}
|
||||
}
|
||||
else if (t.scenePath) {
|
||||
if (!validateSubPath(pv.projectPath, t.scenePath)) {
|
||||
preErrors.set(i, {
|
||||
target: t.scenePath,
|
||||
errors: [
|
||||
{
|
||||
message: 'Invalid scenePath: must be a relative path inside the project root, no ".."',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
else {
|
||||
snakeTargets.push({ scene_path: t.scenePath });
|
||||
}
|
||||
}
|
||||
else {
|
||||
snakeTargets.push({});
|
||||
}
|
||||
}
|
||||
// Short-circuit when every target failed pre-validation — no work for
|
||||
// Godot, and spawning it would just cost ~3s for a no-op.
|
||||
if (snakeTargets.length === 0 && preErrors.size === targets.length) {
|
||||
const results = targets.map((_, i) => {
|
||||
const pre = preErrors.get(i);
|
||||
return { target: pre.target, valid: false, errors: pre.errors };
|
||||
});
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
|
||||
}
|
||||
const { stdout, stderr } = await runner.executeOperation('validate_batch', { targets: snakeTargets }, pv.projectPath);
|
||||
if (!stdout.trim()) {
|
||||
return createErrorResponse(`Batch validate failed: ${extractGdError(stderr)}`, [
|
||||
'Check that all target paths are valid',
|
||||
'Ensure Godot is installed correctly',
|
||||
]);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(stdout.trim());
|
||||
}
|
||||
catch {
|
||||
return createErrorResponse(`Invalid response from validate_batch: ${stdout}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
]);
|
||||
}
|
||||
const errorsByPath = parseGodotErrorsByPath(stderr || '');
|
||||
const godotResults = parsed.results.map((r) => {
|
||||
const key = r.target.startsWith('res://') ? r.target : `res://${r.target}`;
|
||||
const stderrErrors = errorsByPath.get(key) || errorsByPath.get(r.target) || [];
|
||||
const allErrors = stderrErrors.length > 0 ? stderrErrors : r.errors || [];
|
||||
return {
|
||||
target: r.target,
|
||||
valid: r.valid && stderrErrors.length === 0,
|
||||
errors: allErrors,
|
||||
};
|
||||
});
|
||||
// Merge pre-validation failures back into their original positions so
|
||||
// output order matches input order. Pre-validation errors are ours, not
|
||||
// Godot's — they bypass the stderr overlay above.
|
||||
const results = [];
|
||||
let godotIdx = 0;
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (preErrors.has(i)) {
|
||||
const pre = preErrors.get(i);
|
||||
results.push({ target: pre.target, valid: false, errors: pre.errors });
|
||||
}
|
||||
else {
|
||||
results.push(godotResults[godotIdx++]);
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Batch validation failed: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
for (const f of tempFiles) {
|
||||
try {
|
||||
unlinkSync(f);
|
||||
}
|
||||
catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Determine mode — exactly one must be provided
|
||||
const modeCount = [args.scriptPath, args.source, args.scenePath].filter(Boolean).length;
|
||||
if (modeCount === 0) {
|
||||
return createErrorResponse('One of scriptPath, source, or scenePath is required', [
|
||||
'Provide scriptPath to validate an existing .gd file, source to validate inline GDScript, or scenePath to validate a .tscn file',
|
||||
]);
|
||||
}
|
||||
if (modeCount > 1) {
|
||||
return createErrorResponse('Provide exactly one of scriptPath, source, or scenePath — not multiple', ['Only one target can be validated per call']);
|
||||
}
|
||||
let tempFile = false;
|
||||
let resolvedScriptPath;
|
||||
let resolvedScenePath;
|
||||
try {
|
||||
if (args.source) {
|
||||
const { resPath } = writeTempGdScript(pv.projectPath, args.source, 'validate_temp');
|
||||
resolvedScriptPath = resPath;
|
||||
tempFile = true;
|
||||
}
|
||||
else if (args.scriptPath) {
|
||||
if (!validateSubPath(pv.projectPath, args.scriptPath)) {
|
||||
return createErrorResponse('Invalid scriptPath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const fullPath = join(pv.projectPath, args.scriptPath);
|
||||
if (!existsSync(fullPath)) {
|
||||
return createErrorResponse(`Script file does not exist: ${args.scriptPath}`, [
|
||||
'Ensure the path is correct relative to the project directory',
|
||||
]);
|
||||
}
|
||||
resolvedScriptPath = args.scriptPath;
|
||||
}
|
||||
else if (args.scenePath) {
|
||||
if (!validateSubPath(pv.projectPath, args.scenePath)) {
|
||||
return createErrorResponse('Invalid scenePath', [
|
||||
'Provide a valid relative path without ".." that stays inside the project directory',
|
||||
]);
|
||||
}
|
||||
const fullPath = join(pv.projectPath, args.scenePath);
|
||||
if (!existsSync(fullPath)) {
|
||||
return createErrorResponse(`Scene file does not exist: ${args.scenePath}`, [
|
||||
'Ensure the path is correct relative to the project directory',
|
||||
]);
|
||||
}
|
||||
resolvedScenePath = args.scenePath;
|
||||
}
|
||||
const params = {};
|
||||
if (resolvedScriptPath)
|
||||
params.scriptPath = resolvedScriptPath;
|
||||
if (resolvedScenePath)
|
||||
params.scenePath = resolvedScenePath;
|
||||
const { stdout, stderr } = await runner.executeOperation('validate_resource', params, pv.projectPath);
|
||||
// Parse stdout for the base valid/invalid signal from GDScript
|
||||
let valid = false;
|
||||
let gdErrors = [];
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
valid = parsed.valid === true;
|
||||
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
||||
gdErrors = parsed.errors;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// stdout wasn't JSON — treat as invalid
|
||||
valid = false;
|
||||
}
|
||||
// Parse stderr for detailed error messages from Godot's script compiler
|
||||
const stderrErrors = parseGodotErrors(stderr || '');
|
||||
// Merge errors: prefer detailed stderr errors when available, otherwise keep gdErrors
|
||||
const allErrors = stderrErrors.length > 0 ? stderrErrors : gdErrors;
|
||||
// The GDScript-side `valid` flag is unreliable for malformed scripts: load()
|
||||
// returns a non-null placeholder Resource even when parsing fails, so
|
||||
// resource != null is true. Fall back to the parsed stderr errors as the
|
||||
// authoritative signal — matches the batch branch above.
|
||||
const result = {
|
||||
valid: valid && allErrors.length === 0,
|
||||
errors: allErrors,
|
||||
};
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||
}
|
||||
catch (error) {
|
||||
return createErrorResponse(`Validation failed: ${getErrorMessage(error)}`, [
|
||||
'Ensure Godot is installed correctly',
|
||||
'Check if the GODOT_PATH environment variable is set correctly',
|
||||
]);
|
||||
}
|
||||
finally {
|
||||
if (tempFile && resolvedScriptPath) {
|
||||
const tempFilePath = join(pv.projectPath, resolvedScriptPath);
|
||||
try {
|
||||
unlinkSync(tempFilePath);
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=validate-tools.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user