91b878f347
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle) - Created weapon resource files with balanced stats - Wired Pistol + Rifle into player.tscn replacing Kenney blasters - Fixed WASD input (matched input map action names) - Fixed Escape key toggles mouse capture - Added Audio autoload singleton - Fixed broken sound asset paths across all scripts - Fixed all scene ext_resource text paths (nested under assets/kenney-fps/) - Fixed all .import source_file paths - Deleted stale .import files (will be regenerated by editor on open)
544 lines
22 KiB
JavaScript
544 lines
22 KiB
JavaScript
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
|