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 // " (" 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 /.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