diff --git a/README.md b/README.md index 0a0d779..ce2e51a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that pr ## Features -- Tools to run, stop, and capture screenshots from Godot projects -- Resources to monitor running projects, access output streams, and check exit status +- **Tools**: Run/stop Godot projects, search scenes by node type/name/properties, capture screenshots +- **Resources**: Browse scenes and .tres files, query by resource type, monitor running projects with output streams ## Prerequisites diff --git a/eslint.config.js b/eslint.config.js index f185fd0..bd0affd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,7 +21,8 @@ export default [ '@typescript-eslint': tseslint, }, rules: { - '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + 'no-unused-vars': 'off', // Using TypeScript version instead '@typescript-eslint/no-explicit-any': 'warn', 'prefer-const': 'error', 'no-var': 'error', diff --git a/godot-test-project/animation.tres b/godot-test-project/animation.tres new file mode 100644 index 0000000..98360cd --- /dev/null +++ b/godot-test-project/animation.tres @@ -0,0 +1,6 @@ +[gd_resource type="Animation" format=3] + +[resource] +length = 2.0 +loop_mode = 1 +step = 0.1 diff --git a/godot-test-project/test_material.tres b/godot-test-project/test_material.tres new file mode 100644 index 0000000..5517d8b --- /dev/null +++ b/godot-test-project/test_material.tres @@ -0,0 +1,6 @@ +[gd_resource type="StandardMaterial3D" format=3] + +[resource] +albedo_color = Color(0.8, 0.2, 0.2, 1) +metallic = 0.5 +roughness = 0.3 diff --git a/godot-test-project/theme.tres b/godot-test-project/theme.tres new file mode 100644 index 0000000..7186f4f --- /dev/null +++ b/godot-test-project/theme.tres @@ -0,0 +1,4 @@ +[gd_resource type="Theme" format=3] + +[resource] +default_font_size = 16 diff --git a/package-lock.json b/package-lock.json index 55cf1be..11f13f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@fernforestgames/mcp-server-godot", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fernforestgames/mcp-server-godot", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { + "@fernforestgames/godot-resource-parser": "^0.1.1", "@modelcontextprotocol/sdk": "^1.0.0", "node-screenshots": "^0.2.1" }, @@ -671,6 +672,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fernforestgames/godot-resource-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@fernforestgames/godot-resource-parser/-/godot-resource-parser-0.1.1.tgz", + "integrity": "sha512-KdfbmpFV2Txaac2eFykOVf+EpN3sL5FbEOAgpLRez/CkfzfpSqcWf+gKRdL4sr2P32u99wa2pXTLPfRqltifDA==", + "license": "MIT", + "bin": { + "godot-resource-parser": "dist/cli.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index 09fa0f4..b633050 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fernforestgames/mcp-server-godot", - "version": "0.2.2", + "version": "0.3.0", "description": "MCP server for Godot integration", "author": "Justin Spahr-Summers (https://fernforestgames.com)", "license": "MIT", @@ -32,6 +32,7 @@ "node": ">=22.0.0" }, "dependencies": { + "@fernforestgames/godot-resource-parser": "^0.1.1", "@modelcontextprotocol/sdk": "^1.0.0", "node-screenshots": "^0.2.1" }, diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..dd29069 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,13 @@ +// Configuration validation and setup + +// Get Godot project path from command line arguments, default to cwd +const projectPath = process.argv[2] || process.cwd(); + +// Get Godot executable path from environment variable +const godotPath = process.env['GODOT_PATH']; +if (!godotPath) { + console.error("Error: GODOT_PATH environment variable must be set"); + process.exit(1); +} + +export { projectPath, godotPath }; diff --git a/src/handlers/resources/godot-resources.ts b/src/handlers/resources/godot-resources.ts new file mode 100644 index 0000000..7f06897 --- /dev/null +++ b/src/handlers/resources/godot-resources.ts @@ -0,0 +1,156 @@ +import { isGodotResource } from "@fernforestgames/godot-resource-parser"; +import { projectPath } from "../../config.js"; +import { findGodotFiles, parseGodotFile } from "../../utils/files.js"; + +// Resource introspection resources +export const resourcesList = async (uri: URL) => { + const resources = findGodotFiles(projectPath, '.tres'); + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(resources, null, 2) + }] + }; +}; + +// List callback for resource data template +export const resourceDataList = async () => { + const resources = findGodotFiles(projectPath, '.tres'); + const resourceList = resources.map(resourcePath => ({ + uri: `godot://project/resources/${resourcePath}`, + name: `resource-${resourcePath}`, + mimeType: "application/json" + })); + return { resources: resourceList }; +}; + +export const resourceData = async (uri: URL, params: any) => { + const resourcePath = params['resourcePath...']; + if (!resourcePath) { + throw new Error(`resourcePath parameter is required, got: ${JSON.stringify(params)}`); + } + const resourcePathStr = resourcePath; + const parsed = parseGodotFile(projectPath, resourcePathStr); + + if (!isGodotResource(parsed)) { + throw new Error(`File ${resourcePathStr} is not a resource file`); + } + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(parsed, null, 2) + }] + }; +}; + +// Resource type query resources +export const resourceTypesList = async (uri: URL) => { + const resources = findGodotFiles(projectPath, '.tres'); + const typeSet = new Set(); + + for (const resourcePath of resources) { + try { + const parsed = parseGodotFile(projectPath, resourcePath); + if (isGodotResource(parsed)) { + typeSet.add(parsed.header.resourceType); + } + } catch (error) { + // Skip files we can't parse + console.error(`Warning: Failed to parse resource ${resourcePath}:`, error); + } + } + + const types = Array.from(typeSet).sort(); + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(types, null, 2) + }] + }; +}; + +// List callback for resources by type template +export const resourcesByTypeList = async () => { + const resources = findGodotFiles(projectPath, '.tres'); + const typeSet = new Set(); + + for (const resourcePath of resources) { + try { + const parsed = parseGodotFile(projectPath, resourcePath); + if (isGodotResource(parsed)) { + typeSet.add(parsed.header.resourceType); + } + } catch (error) { + // Skip files we can't parse + console.error(`Warning: Failed to parse resource ${resourcePath}:`, error); + } + } + + const resourceList = Array.from(typeSet).map(type => ({ + uri: `godot://project/resourceTypes/${type}`, + name: `type-${type}`, + mimeType: "application/json" + })); + + return { resources: resourceList }; +}; + +export const resourcesByType = async (uri: URL, { type }: any) => { + const typeStr = type as string; + const resources = findGodotFiles(projectPath, '.tres'); + const matchingResources: string[] = []; + + for (const resourcePath of resources) { + try { + const parsed = parseGodotFile(projectPath, resourcePath); + if (isGodotResource(parsed) && parsed.header.resourceType === typeStr) { + matchingResources.push(resourcePath); + } + } catch (error) { + // Skip files we can't parse + console.error(`Warning: Failed to parse resource ${resourcePath}:`, error); + } + } + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(matchingResources, null, 2) + }] + }; +}; + +export const resourcePropertyByType = async (uri: URL, { type, property }: any) => { + const typeStr = type as string; + const propertyStr = property as string; + const resources = findGodotFiles(projectPath, '.tres'); + const results: Array<{ path: string; value: unknown }> = []; + + for (const resourcePath of resources) { + try { + const parsed = parseGodotFile(projectPath, resourcePath); + if (isGodotResource(parsed) && parsed.header.resourceType === typeStr) { + // Check if property exists in resource section + if (parsed.resource?.properties[propertyStr] !== undefined) { + results.push({ + path: resourcePath, + value: parsed.resource.properties[propertyStr] + }); + } + } + } catch (error) { + // Skip files we can't parse + console.error(`Warning: Failed to parse resource ${resourcePath}:`, error); + } + } + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(results, null, 2) + }] + }; +}; diff --git a/src/handlers/resources/runs.ts b/src/handlers/resources/runs.ts new file mode 100644 index 0000000..637abb3 --- /dev/null +++ b/src/handlers/resources/runs.ts @@ -0,0 +1,104 @@ +import { type ProjectRun } from "../../types.js"; + +// Resources for project management +export const runsList = async (uri: URL, runningProjects: Map) => { + const runs = Array.from(runningProjects.values()).map(run => ({ + id: run.id, + projectPath: run.projectPath, + status: run.status, + startTime: run.startTime.toISOString(), + exitCode: run.exitCode, + args: run.args + })); + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(runs, null, 2) + }] + }; +}; + +// List callback for project stdout template +export const projectStdoutList = (runningProjects: Map) => async () => { + const resources = Array.from(runningProjects.keys()).map(runId => ({ + uri: `godot://runs/${runId}/stdout`, + name: `stdout-${runId}`, + mimeType: "text/plain" + })); + return { resources }; +}; + +export const projectStdout = async (uri: URL, { runId }: any, runningProjects: Map) => { + const projectRun = runningProjects.get(runId as string); + + if (!projectRun) { + throw new Error(`No project found with run ID: ${runId}`); + } + + return { + contents: [{ + uri: uri.href, + text: projectRun.stdout.join('') + }] + }; +}; + +// List callback for project stderr template +export const projectStderrList = (runningProjects: Map) => async () => { + const resources = Array.from(runningProjects.keys()).map(runId => ({ + uri: `godot://runs/${runId}/stderr`, + name: `stderr-${runId}`, + mimeType: "text/plain" + })); + return { resources }; +}; + +export const projectStderr = async (uri: URL, { runId }: any, runningProjects: Map) => { + const projectRun = runningProjects.get(runId as string); + + if (!projectRun) { + throw new Error(`No project found with run ID: ${runId}`); + } + + return { + contents: [{ + uri: uri.href, + text: projectRun.stderr.join('') + }] + }; +}; + +// List callback for project status template +export const projectStatusList = (runningProjects: Map) => async () => { + const resources = Array.from(runningProjects.keys()).map(runId => ({ + uri: `godot://runs/${runId}/status`, + name: `status-${runId}`, + mimeType: "application/json" + })); + return { resources }; +}; + +export const projectStatus = async (uri: URL, { runId }: any, runningProjects: Map) => { + const projectRun = runningProjects.get(runId as string); + + if (!projectRun) { + throw new Error(`No project found with run ID: ${runId}`); + } + + const status = { + id: projectRun.id, + status: projectRun.status, + projectPath: projectRun.projectPath, + startTime: projectRun.startTime.toISOString(), + exitCode: projectRun.exitCode, + args: projectRun.args + }; + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(status, null, 2) + }] + }; +}; diff --git a/src/handlers/resources/scenes.ts b/src/handlers/resources/scenes.ts new file mode 100644 index 0000000..fd1d797 --- /dev/null +++ b/src/handlers/resources/scenes.ts @@ -0,0 +1,113 @@ +import { isGodotScene, type Node as GodotNode } from "@fernforestgames/godot-resource-parser"; +import { projectPath } from "../../config.js"; +import { findGodotFiles, parseGodotFile } from "../../utils/files.js"; +import { getFullNodePath, getNodeByPath } from "../../utils/scenes.js"; + +// Scene structure resources +export const scenesList = async (uri: URL) => { + const scenes = findGodotFiles(projectPath, '.tscn'); + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(scenes, null, 2) + }] + }; +}; + +// List callback for scene data template +export const sceneDataList = async () => { + const scenes = findGodotFiles(projectPath, '.tscn'); + const resources = scenes.map(scenePath => ({ + uri: `godot://project/scenes/${scenePath}`, + name: `scene-${scenePath}`, + mimeType: "application/json" + })); + return { resources }; +}; + +export const sceneData = async (uri: URL, params: any) => { + const scenePath = params['scenePath...']; + if (!scenePath) { + throw new Error(`scenePath parameter is required, got: ${JSON.stringify(params)}`); + } + const scenePathStr = scenePath; + const parsed = parseGodotFile(projectPath, scenePathStr); + + if (!isGodotScene(parsed)) { + throw new Error(`File ${scenePathStr} is not a scene file`); + } + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(parsed, null, 2) + }] + }; +}; + +// List callback for scene nodes template +export const sceneNodesList = async () => { + const scenes = findGodotFiles(projectPath, '.tscn'); + const resources = scenes.map(scenePath => ({ + uri: `godot://project/scenes/${scenePath}/nodes`, + name: `nodes-${scenePath}`, + mimeType: "application/json" + })); + return { resources }; +}; + +export const sceneNodes = async (uri: URL, params: any) => { + const scenePath = params['scenePath...']; + if (!scenePath) { + throw new Error(`scenePath parameter is required, got: ${JSON.stringify(params)}`); + } + const scenePathStr = scenePath; + const parsed = parseGodotFile(projectPath, scenePathStr); + + if (!isGodotScene(parsed)) { + throw new Error(`File ${scenePathStr} is not a scene file`); + } + + const nodeHierarchy = parsed.nodes.map((node: GodotNode) => ({ + name: node.name, + type: node.type, + parent: node.parent, + fullPath: getFullNodePath(parsed, node), + hasProperties: Object.keys(node.properties).length > 0 + })); + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(nodeHierarchy, null, 2) + }] + }; +}; + +export const sceneNodeDetail = async (uri: URL, params: any) => { + const scenePath = params['scenePath...']; + const nodePath = params['nodePath...']; + if (!scenePath || !nodePath) { + throw new Error(`scenePath and nodePath parameters are required, got: ${JSON.stringify(params)}`); + } + const scenePathStr = scenePath; + const nodePathStr = nodePath; + const parsed = parseGodotFile(projectPath, scenePathStr); + + if (!isGodotScene(parsed)) { + throw new Error(`File ${scenePathStr} is not a scene file`); + } + + const node = getNodeByPath(parsed, nodePathStr); + if (!node) { + throw new Error(`Node ${nodePathStr} not found in scene ${scenePathStr}`); + } + + return { + contents: [{ + uri: uri.href, + text: JSON.stringify(node, null, 2) + }] + }; +}; diff --git a/src/handlers/tools/run-project.ts b/src/handlers/tools/run-project.ts new file mode 100644 index 0000000..398df94 --- /dev/null +++ b/src/handlers/tools/run-project.ts @@ -0,0 +1,81 @@ +import { type ChildProcess, spawn } from "child_process"; +import { randomUUID } from "crypto"; +import { godotPath, projectPath as defaultProjectPath } from "../../config.js"; +import { type ProjectRun } from "../../types.js"; + +export async function runProject( + runningProjects: Map, + { projectPath: customProjectPath, args }: { projectPath?: string | undefined; args?: string[] | undefined } +) { + const targetProjectPath = customProjectPath || defaultProjectPath; + if (!targetProjectPath) { + return { + content: [{ type: "text" as const, text: "Project path is not defined" }] + }; + } + + const runId = randomUUID(); + + try { + const godotArgs = ["--path", targetProjectPath]; + if (args) { + godotArgs.push(...args); + } + + if (!godotPath) { + return { + content: [{ type: "text" as const, text: "GODOT_PATH environment variable is not set" }] + }; + } + + const process: ChildProcess = spawn(godotPath, godotArgs, { + stdio: ["inherit", "pipe", "pipe"] + }); + + const projectRun: ProjectRun = { + id: runId, + process, + projectPath: targetProjectPath, + stdout: [], + stderr: [], + status: 'running', + startTime: new Date(), + ...(args && { args }) + }; + + runningProjects.set(runId, projectRun); + + // Capture stdout + process.stdout?.on("data", (data) => { + const output = data.toString(); + projectRun.stdout.push(output); + }); + + // Capture stderr + process.stderr?.on("data", (data) => { + const output = data.toString(); + projectRun.stderr.push(output); + }); + + // Handle process exit + process.on("exit", (code) => { + projectRun.status = 'exited'; + projectRun.exitCode = code || 0; + }); + + // Handle process errors + process.on("error", (error) => { + projectRun.stderr.push(`Failed to start Godot: ${error.message}`); + projectRun.status = 'exited'; + projectRun.exitCode = 1; + }); + + return { + content: [{ type: "text" as const, text: `Godot project started with run ID: ${runId}\nProject path: ${targetProjectPath}` }] + }; + } catch (error) { + return { + content: [{ type: "text" as const, text: `Failed to launch Godot: ${error}` }] + }; + } +} diff --git a/src/handlers/tools/screenshot.ts b/src/handlers/tools/screenshot.ts new file mode 100644 index 0000000..83679f2 --- /dev/null +++ b/src/handlers/tools/screenshot.ts @@ -0,0 +1,136 @@ +import * as fs from "fs"; +import { Monitor, Window } from "node-screenshots"; +import * as path from "path"; + +export async function captureScreenshot({ + target = "godot", + format = "png", + outputPath +}: { + target?: "godot" | "all_monitors" | "primary_monitor" | undefined; + format?: "png" | "jpeg" | "bmp" | undefined; + outputPath?: string | undefined; +}) { + try { + let imageBuffer: Buffer; + let screenshotInfo = ""; + + if (target === "godot") { + // Find Godot windows + const windows = Window.all(); + const godotWindows = windows.filter(window => { + // Look for Godot application by app name instead of title + const appName = window.appName || ""; + return appName.toLowerCase().includes("godot"); + }); + + if (godotWindows.length === 0) { + return { + content: [{ type: "text" as const, text: "No Godot windows found. Available windows:\n" + + windows.map(w => `- ${w.title || 'Untitled'} [${w.appName || 'Unknown'}] (${w.width}x${w.height})`).join("\n") }] + }; + } + + // Use the first Godot window found + const godotWindow = godotWindows[0]; + if (!godotWindow) { + return { + content: [{ type: "text" as const, text: "No Godot window available for capture" }] + }; + } + const image = godotWindow.captureImageSync(); + + switch (format) { + case "png": + imageBuffer = image.toPngSync(); + break; + case "jpeg": + imageBuffer = image.toJpegSync(); + break; + case "bmp": + imageBuffer = image.toBmpSync(); + break; + } + + screenshotInfo = `Captured Godot window: ${godotWindow.title || 'Untitled'} (${godotWindow.width}x${godotWindow.height})`; + } else if (target === "all_monitors") { + const monitors = Monitor.all(); + if (monitors.length === 0) { + return { + content: [{ type: "text" as const, text: "No monitors found" }] + }; + } + + // Capture primary monitor for now (could be extended to capture all) + const primaryMonitor = monitors.find(m => m.isPrimary) || monitors[0]; + if (!primaryMonitor) { + return { + content: [{ type: "text" as const, text: "No primary monitor found" }] + }; + } + const image = primaryMonitor.captureImageSync(); + + switch (format) { + case "png": + imageBuffer = image.toPngSync(); + break; + case "jpeg": + imageBuffer = image.toJpegSync(); + break; + case "bmp": + imageBuffer = image.toBmpSync(); + break; + } + + screenshotInfo = `Captured primary monitor (${primaryMonitor.width}x${primaryMonitor.height})`; + } else { // primary_monitor + const monitors = Monitor.all(); + const primaryMonitor = monitors.find(m => m.isPrimary) || monitors[0]; + + if (!primaryMonitor) { + return { + content: [{ type: "text" as const, text: "No primary monitor found" }] + }; + } + + const image = primaryMonitor.captureImageSync(); + + switch (format) { + case "png": + imageBuffer = image.toPngSync(); + break; + case "jpeg": + imageBuffer = image.toJpegSync(); + break; + case "bmp": + imageBuffer = image.toBmpSync(); + break; + } + + screenshotInfo = `Captured primary monitor (${primaryMonitor.width}x${primaryMonitor.height})`; + } + + if (outputPath) { + // Save to file + const resolvedPath = path.resolve(outputPath); + fs.writeFileSync(resolvedPath, imageBuffer); + return { + content: [{ type: "text" as const, text: `${screenshotInfo}\nScreenshot saved to: ${resolvedPath}` }] + }; + } else { + // Return base64 encoded data + const base64Data = imageBuffer.toString('base64'); + return { + content: [{ + type: "image" as const, + data: base64Data, + mimeType: `image/${format}`, + }] + }; + } + } catch (error) { + return { + content: [{ type: "text" as const, text: `Failed to capture screenshot: ${error}` }] + }; + } +} diff --git a/src/handlers/tools/search-scenes.ts b/src/handlers/tools/search-scenes.ts new file mode 100644 index 0000000..4d75056 --- /dev/null +++ b/src/handlers/tools/search-scenes.ts @@ -0,0 +1,89 @@ +import { isGodotScene } from "@fernforestgames/godot-resource-parser"; +import { projectPath } from "../../config.js"; +import { findGodotFiles, parseGodotFile } from "../../utils/files.js"; +import { getFullNodePath } from "../../utils/scenes.js"; + +export async function searchScenes({ + nodeType, + namePattern, + propertyName, + propertyValue, + limit = 100, + offset = 0 +}: { + nodeType?: string | undefined; + namePattern?: string | undefined; + propertyName?: string | undefined; + propertyValue?: string | undefined; + limit?: number | undefined; + offset?: number | undefined; +}) { + const scenes = findGodotFiles(projectPath, '.tscn'); + const results: Array<{ scene: string; node: string; type: string; properties?: Record }> = []; + + for (const scenePath of scenes) { + try { + const parsed = parseGodotFile(projectPath, scenePath); + if (!isGodotScene(parsed)) continue; + + for (const node of parsed.nodes) { + let matches = true; + + // Filter by node type + if (nodeType && node.type !== nodeType) { + matches = false; + } + + // Filter by name pattern (case-insensitive) + if (namePattern && !node.name.toLowerCase().includes(namePattern.toLowerCase())) { + matches = false; + } + + // Filter by property name + if (propertyName && !(propertyName in node.properties)) { + matches = false; + } + + // Filter by property value + if (propertyValue && propertyName) { + const propValue = node.properties[propertyName]; + if (propValue !== propertyValue && String(propValue) !== propertyValue) { + matches = false; + } + } + + if (matches) { + results.push({ + scene: scenePath, + node: getFullNodePath(parsed, node), + type: node.type, + ...(Object.keys(node.properties).length > 0 && { properties: node.properties }) + }); + } + } + } catch (error) { + // Skip scenes we can't parse + console.error(`Warning: Failed to parse scene ${scenePath}:`, error); + } + } + + const totalResults = results.length; + const paginatedResults = results.slice(offset, offset + limit); + const hasMore = (offset + limit) < totalResults; + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + results: paginatedResults, + pagination: { + total: totalResults, + offset, + limit, + returned: paginatedResults.length, + hasMore + } + }, null, 2) + }] + }; +} diff --git a/src/handlers/tools/stop-project.ts b/src/handlers/tools/stop-project.ts new file mode 100644 index 0000000..184c4c8 --- /dev/null +++ b/src/handlers/tools/stop-project.ts @@ -0,0 +1,30 @@ +import { type ProjectRun } from "../../types.js"; + +export async function stopProject( + runningProjects: Map, + { runId }: { runId: string } +) { + const projectRun = runningProjects.get(runId); + if (!projectRun) { + return { + content: [{ type: "text" as const, text: `No project found with run ID: ${runId}` }] + }; + } + + if (projectRun.status === 'exited') { + return { + content: [{ type: "text" as const, text: `Project with run ID ${runId} has already exited` }] + }; + } + + try { + projectRun.process.kill(); + return { + content: [{ type: "text" as const, text: `Stopped project with run ID: ${runId}` }] + }; + } catch (error) { + return { + content: [{ type: "text" as const, text: `Failed to stop project ${runId}: ${error}` }] + }; + } +} diff --git a/src/index.ts b/src/index.ts index 3dc08a3..ba14f64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,49 +1,26 @@ #!/usr/bin/env node import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { ChildProcess, spawn } from "child_process"; -import { randomUUID } from "crypto"; -import * as fs from "fs"; -import { Monitor, Window } from "node-screenshots"; -import * as path from "path"; import { z } from "zod"; - -// Get Godot project path from command line arguments -const projectPath = process.argv[2]; -if (!projectPath) { - console.error("Error: Godot project path must be provided as a command line argument"); - process.exit(1); -} - -// Get Godot executable path from environment variable -const godotPath = process.env['GODOT_PATH']; -if (!godotPath) { - console.error("Error: GODOT_PATH environment variable must be set"); - process.exit(1); -} +import "./config.js"; // Validate configuration on startup +import { runProject } from "./handlers/tools/run-project.js"; +import { stopProject } from "./handlers/tools/stop-project.js"; +import { searchScenes } from "./handlers/tools/search-scenes.js"; +import { captureScreenshot } from "./handlers/tools/screenshot.js"; +import * as sceneResources from "./handlers/resources/scenes.js"; +import * as godotResources from "./handlers/resources/godot-resources.js"; +import * as runResources from "./handlers/resources/runs.js"; +import { type ProjectRun } from "./types.js"; const server = new McpServer({ name: "mcp-server-godot", version: "0.1.0", }); -// Types for project management -interface ProjectRun { - id: string; - process: ChildProcess; - projectPath: string; - stdout: string[]; - stderr: string[]; - status: 'running' | 'exited'; - exitCode?: number; - startTime: Date; - args?: string[]; -} - -// Storage for running projects +// Storage for running projects (tied to MCP server lifetime) const runningProjects = new Map(); -// Tool to run a Godot project +// Register tools server.registerTool("run_project", { title: "Run Godot Project", @@ -53,70 +30,9 @@ server.registerTool("run_project", args: z.array(z.string()).optional().describe("Optional arguments to pass to Godot on startup") } }, - async ({ projectPath: customProjectPath, args }) => { - const targetProjectPath = customProjectPath || projectPath; - const runId = randomUUID(); - - try { - const godotArgs = ["--path", targetProjectPath]; - if (args) { - godotArgs.push(...args); - } - - const process = spawn(godotPath, godotArgs, { - stdio: ["inherit", "pipe", "pipe"] - }); - - const projectRun: ProjectRun = { - id: runId, - process, - projectPath: targetProjectPath, - stdout: [], - stderr: [], - status: 'running', - startTime: new Date(), - ...(args && { args }) - }; - - runningProjects.set(runId, projectRun); - - // Capture stdout - process.stdout?.on("data", (data) => { - const output = data.toString(); - projectRun.stdout.push(output); - }); - - // Capture stderr - process.stderr?.on("data", (data) => { - const output = data.toString(); - projectRun.stderr.push(output); - }); - - // Handle process exit - process.on("exit", (code) => { - projectRun.status = 'exited'; - projectRun.exitCode = code || 0; - }); - - // Handle process errors - process.on("error", (error) => { - projectRun.stderr.push(`Failed to start Godot: ${error.message}`); - projectRun.status = 'exited'; - projectRun.exitCode = 1; - }); - - return { - content: [{ type: "text", text: `Godot project started with run ID: ${runId}\nProject path: ${targetProjectPath}` }] - }; - } catch (error) { - return { - content: [{ type: "text", text: `Failed to launch Godot: ${error}` }] - }; - } - } + async (params) => runProject(runningProjects, params) ); -// Tool to stop a Godot project server.registerTool("stop_project", { title: "Stop Godot Project", @@ -125,299 +41,176 @@ server.registerTool("stop_project", runId: z.string().describe("The run ID of the project to stop") } }, - async ({ runId }) => { - const projectRun = runningProjects.get(runId); - if (!projectRun) { - return { - content: [{ type: "text", text: `No project found with run ID: ${runId}` }] - }; - } - - if (projectRun.status === 'exited') { - return { - content: [{ type: "text", text: `Project with run ID ${runId} has already exited` }] - }; - } + async (params) => stopProject(runningProjects, params) +); - try { - projectRun.process.kill(); - return { - content: [{ type: "text", text: `Stopped project with run ID: ${runId}` }] - }; - } catch (error) { - return { - content: [{ type: "text", text: `Failed to stop project ${runId}: ${error}` }] - }; +server.registerTool("search_scenes", + { + title: "Search Scenes", + description: "Find nodes or scenes matching criteria (by node type, name pattern, or property)", + inputSchema: { + nodeType: z.string().optional().describe("Filter by node type (e.g., 'CharacterBody2D')"), + namePattern: z.string().optional().describe("Filter by name pattern (case-insensitive substring match)"), + propertyName: z.string().optional().describe("Filter by nodes that have a specific property"), + propertyValue: z.string().optional().describe("Filter by nodes where property has a specific value (requires propertyName)"), + limit: z.number().default(100).describe("Maximum number of results to return"), + offset: z.number().default(0).describe("Number of results to skip for pagination") } - } + }, + searchScenes ); -// Tool to capture screenshot server.registerTool("capture_screenshot", { title: "Capture Screenshot", description: "Capture a screenshot of the Godot game window or all monitors", inputSchema: { - target: z.enum(["godot", "all_monitors", "primary_monitor"]).optional().describe("Screenshot target: 'godot' for Godot window, 'all_monitors' for all monitors, 'primary_monitor' for primary monitor (default: godot)"), - format: z.enum(["png", "jpeg", "bmp"]).optional().describe("Image format (default: png)"), + target: z.enum(["godot", "all_monitors", "primary_monitor"]).default("godot").describe("Screenshot target: 'godot' for Godot window, 'all_monitors' for all monitors, 'primary_monitor' for primary monitor"), + format: z.enum(["png", "jpeg", "bmp"]).default("png").describe("Image format"), outputPath: z.string().optional().describe("Optional output file path. If not provided, returns base64 encoded image data") } }, - async ({ target = "godot", format = "png", outputPath }) => { - try { - let imageBuffer: Buffer; - let screenshotInfo = ""; - - if (target === "godot") { - // Find Godot windows - const windows = Window.all(); - const godotWindows = windows.filter(window => { - // Look for Godot application by app name instead of title - const appName = window.appName || ""; - return appName.toLowerCase().includes("godot"); - }); - - if (godotWindows.length === 0) { - return { - content: [{ type: "text", text: "No Godot windows found. Available windows:\n" + - windows.map(w => `- ${w.title || 'Untitled'} [${w.appName || 'Unknown'}] (${w.width}x${w.height})`).join("\n") }] - }; - } - - // Use the first Godot window found - const godotWindow = godotWindows[0]; - if (!godotWindow) { - return { - content: [{ type: "text", text: "No Godot window available for capture" }] - }; - } - const image = godotWindow.captureImageSync(); - - switch (format) { - case "png": - imageBuffer = image.toPngSync(); - break; - case "jpeg": - imageBuffer = image.toJpegSync(); - break; - case "bmp": - imageBuffer = image.toBmpSync(); - break; - } + captureScreenshot +); - screenshotInfo = `Captured Godot window: ${godotWindow.title || 'Untitled'} (${godotWindow.width}x${godotWindow.height})`; - } else if (target === "all_monitors") { - const monitors = Monitor.all(); - if (monitors.length === 0) { - return { - content: [{ type: "text", text: "No monitors found" }] - }; - } +// Register scene resources +server.registerResource("scenes_list", "godot://project/scenes/", + { + title: "Project Scenes", + description: "List all .tscn scene files in the Godot project", + mimeType: "application/json" + }, + sceneResources.scenesList +); - // Capture primary monitor for now (could be extended to capture all) - const primaryMonitor = monitors.find(m => m.isPrimary) || monitors[0]; - if (!primaryMonitor) { - return { - content: [{ type: "text", text: "No primary monitor found" }] - }; - } - const image = primaryMonitor.captureImageSync(); +server.registerResource("scene_data", new ResourceTemplate("godot://project/scenes/{scenePath...}", { + list: sceneResources.sceneDataList +}), + { + title: "Scene Data", + description: "Get full parsed scene data including nodes, connections, and resources", + mimeType: "application/json" + }, + sceneResources.sceneData +); - switch (format) { - case "png": - imageBuffer = image.toPngSync(); - break; - case "jpeg": - imageBuffer = image.toJpegSync(); - break; - case "bmp": - imageBuffer = image.toBmpSync(); - break; - } +server.registerResource("scene_nodes", new ResourceTemplate("godot://project/scenes/{scenePath...}/nodes", { + list: sceneResources.sceneNodesList +}), + { + title: "Scene Node Hierarchy", + description: "Get the node hierarchy with names, types, and parent relationships", + mimeType: "application/json" + }, + sceneResources.sceneNodes +); - screenshotInfo = `Captured primary monitor (${primaryMonitor.width}x${primaryMonitor.height})`; - } else { // primary_monitor - const monitors = Monitor.all(); - const primaryMonitor = monitors.find(m => m.isPrimary) || monitors[0]; +server.registerResource("scene_node_detail", new ResourceTemplate("godot://project/scenes/{scenePath...}/nodes/{nodePath...}", { + list: undefined +}), + { + title: "Scene Node Details", + description: "Get detailed information about a specific node including all properties", + mimeType: "application/json" + }, + sceneResources.sceneNodeDetail +); - if (!primaryMonitor) { - return { - content: [{ type: "text", text: "No primary monitor found" }] - }; - } +// Register Godot resource files +server.registerResource("resources_list", "godot://project/resources/", + { + title: "Project Resources", + description: "List all .tres resource files in the Godot project", + mimeType: "application/json" + }, + godotResources.resourcesList +); - const image = primaryMonitor.captureImageSync(); +server.registerResource("resource_data", new ResourceTemplate("godot://project/resources/{resourcePath...}", { + list: godotResources.resourceDataList +}), + { + title: "Resource Data", + description: "Get full parsed resource data", + mimeType: "application/json" + }, + godotResources.resourceData +); - switch (format) { - case "png": - imageBuffer = image.toPngSync(); - break; - case "jpeg": - imageBuffer = image.toJpegSync(); - break; - case "bmp": - imageBuffer = image.toBmpSync(); - break; - } +server.registerResource("resource_types_list", "godot://project/resourceTypes/", + { + title: "Resource Types", + description: "List all resource types found in the project", + mimeType: "application/json" + }, + godotResources.resourceTypesList +); - screenshotInfo = `Captured primary monitor (${primaryMonitor.width}x${primaryMonitor.height})`; - } +server.registerResource("resources_by_type", new ResourceTemplate("godot://project/resourceTypes/{type}", { + list: godotResources.resourcesByTypeList +}), + { + title: "Resources by Type", + description: "List all resources of a specific type with their paths", + mimeType: "application/json" + }, + godotResources.resourcesByType +); - if (outputPath) { - // Save to file - const resolvedPath = path.resolve(outputPath); - fs.writeFileSync(resolvedPath, imageBuffer); - return { - content: [{ type: "text", text: `${screenshotInfo}\nScreenshot saved to: ${resolvedPath}` }] - }; - } else { - // Return base64 encoded data - const base64Data = imageBuffer.toString('base64'); - return { - content: [{ - type: "image", - data: base64Data, - mimeType: `image/${format}`, - }] - }; - } - } catch (error) { - return { - content: [{ type: "text", text: `Failed to capture screenshot: ${error}` }] - }; - } - } +server.registerResource("resources_property_by_type", new ResourceTemplate("godot://project/resourceTypes/{type}/{property}", { + list: undefined +}), + { + title: "Resource Property by Type", + description: "Get a specific property value from all resources of a given type", + mimeType: "application/json" + }, + godotResources.resourcePropertyByType ); -// Resources for project management +// Register run management resources server.registerResource("runs_list", "godot://runs/", { title: "Running Projects", description: "List all currently running Godot projects", mimeType: "application/json" }, - async (uri) => { - const runs = Array.from(runningProjects.values()).map(run => ({ - id: run.id, - projectPath: run.projectPath, - status: run.status, - startTime: run.startTime.toISOString(), - exitCode: run.exitCode, - args: run.args - })); - - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(runs, null, 2) - }] - }; - } + async (uri) => runResources.runsList(uri, runningProjects) ); server.registerResource("project_stdout", new ResourceTemplate("godot://runs/{runId}/stdout", { - list: async () => { - const resources = Array.from(runningProjects.keys()).map(runId => ({ - uri: `godot://runs/${runId}/stdout`, - name: `stdout-${runId}`, - mimeType: "text/plain" - })); - return { resources }; - } + list: runResources.projectStdoutList(runningProjects) }), { title: "Project Standard Output", description: "Get the standard output for a specific project run", mimeType: "text/plain" }, - async (uri, { runId }) => { - const projectRun = runningProjects.get(runId as string); - - if (!projectRun) { - throw new Error(`No project found with run ID: ${runId}`); - } - - return { - contents: [{ - uri: uri.href, - text: projectRun.stdout.join('') - }] - }; - } + async (uri, params) => runResources.projectStdout(uri, params, runningProjects) ); server.registerResource("project_stderr", new ResourceTemplate("godot://runs/{runId}/stderr", { - list: async () => { - const resources = Array.from(runningProjects.keys()).map(runId => ({ - uri: `godot://runs/${runId}/stderr`, - name: `stderr-${runId}`, - mimeType: "text/plain" - })); - return { resources }; - } + list: runResources.projectStderrList(runningProjects) }), { title: "Project Standard Error", description: "Get the standard error for a specific project run", mimeType: "text/plain" }, - async (uri, { runId }) => { - const projectRun = runningProjects.get(runId as string); - - if (!projectRun) { - throw new Error(`No project found with run ID: ${runId}`); - } - - return { - contents: [{ - uri: uri.href, - text: projectRun.stderr.join('') - }] - }; - } + async (uri, params) => runResources.projectStderr(uri, params, runningProjects) ); server.registerResource("project_status", new ResourceTemplate("godot://runs/{runId}/status", { - list: async () => { - const resources = Array.from(runningProjects.keys()).map(runId => ({ - uri: `godot://runs/${runId}/status`, - name: `status-${runId}`, - mimeType: "application/json" - })); - return { resources }; - } + list: runResources.projectStatusList(runningProjects) }), { title: "Project Status", description: "Get the status information for a specific project run", mimeType: "application/json" }, - async (uri, { runId }) => { - const projectRun = runningProjects.get(runId as string); - - if (!projectRun) { - throw new Error(`No project found with run ID: ${runId}`); - } - - const status = { - id: projectRun.id, - status: projectRun.status, - projectPath: projectRun.projectPath, - startTime: projectRun.startTime.toISOString(), - exitCode: projectRun.exitCode, - args: projectRun.args - }; - - return { - contents: [{ - uri: uri.href, - text: JSON.stringify(status, null, 2) - }] - }; - } + async (uri, params) => runResources.projectStatus(uri, params, runningProjects) ); - // Clean up on process exit process.on("exit", () => { for (const projectRun of runningProjects.values()) { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..957497a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,14 @@ +import { ChildProcess } from "child_process"; + +// Types for project management +export interface ProjectRun { + id: string; + process: ChildProcess; + projectPath: string; + stdout: string[]; + stderr: string[]; + status: 'running' | 'exited'; + exitCode?: number; + startTime: Date; + args?: string[]; +} diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 0000000..cde4a7f --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,49 @@ +import { parse, type GodotResource, type GodotScene } from "@fernforestgames/godot-resource-parser"; +import * as fs from "fs"; +import * as path from "path"; + +// Helper functions for file discovery +export function findGodotFiles(directory: string | undefined, extension: string): string[] { + if (!directory) { + return []; + } + + const rootDir = directory; // Capture in const to satisfy TypeScript + const results: string[] = []; + + function searchDir(dir: string) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip hidden directories and common non-project directories + if (!entry.name.startsWith('.') && entry.name !== 'addons') { + searchDir(fullPath); + } + } else if (entry.isFile() && entry.name.endsWith(extension)) { + // Return path relative to project root + const relativePath = path.relative(rootDir, fullPath); + results.push(relativePath.replace(/\\/g, '/')); + } + } + } catch (error) { + // Skip directories we can't read + console.error(`Warning: Failed to read directory ${dir}:`, error); + } + } + + searchDir(rootDir); + return results; +} + +export function parseGodotFile(projectPath: string | undefined, filePath: string): GodotScene | GodotResource { + if (!projectPath) { + throw new Error("Project path is not defined"); + } + const fullPath = path.join(projectPath, filePath); + const content = fs.readFileSync(fullPath, 'utf-8'); + return parse(content); +} diff --git a/src/utils/scenes.ts b/src/utils/scenes.ts new file mode 100644 index 0000000..7cd2f3c --- /dev/null +++ b/src/utils/scenes.ts @@ -0,0 +1,30 @@ +import { type GodotScene, type Node as GodotNode } from "@fernforestgames/godot-resource-parser"; + +export function getNodeByPath(scene: GodotScene, nodePath: string): GodotNode | undefined { + // Node path is like "." for root, "Player" for child, "Player/Sprite" for nested + if (nodePath === ".") { + return scene.nodes.find((node: GodotNode) => !node.parent || node.parent === "."); + } + + return scene.nodes.find((node: GodotNode) => { + if (node.parent === ".") { + return node.name === nodePath; + } + // For nested nodes, construct full path + const fullNodePath = getFullNodePath(scene, node); + return fullNodePath === nodePath; + }); +} + +export function getFullNodePath(scene: GodotScene, node: GodotNode): string { + if (!node.parent || node.parent === ".") { + return node.name; + } + + const parentNode = scene.nodes.find((n: GodotNode) => n.name === node.parent); + if (!parentNode) { + return node.name; + } + + return `${getFullNodePath(scene, parentNode)}/${node.name}`; +}