scad-gltf 0.1.4 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -3
- package/bevy/README.md +46 -0
- package/bevy/examples/LICENSE +21 -0
- package/bevy/examples/README.md +58 -0
- package/bevy/examples/package.json +5 -0
- package/bevy/examples/packman_3d.js +2415 -0
- package/bin/scad-bevy.js +74 -0
- package/bin/scad-gen.js +25 -0
- package/bin/scad-godot.js +65 -222
- package/bin/scad-mcp.js +641 -2
- package/bin/scad-play.js +172 -0
- package/bin/scad-web.js +14 -166
- package/package.json +7 -2
- package/src/cli-utils.js +792 -0
- package/src/godot-utils.js +179 -0
package/bin/scad-mcp.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { spawn, execSync } from "child_process";
|
|
4
6
|
import { fileURLToPath } from "url";
|
|
5
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
import puppeteer from "puppeteer";
|
|
12
14
|
import { convertScadToGltf } from "../src/convert.js";
|
|
13
15
|
import { generatePrompt } from "../src/prompt.js";
|
|
16
|
+
import { runGodotAsync } from "../src/godot-utils.js";
|
|
14
17
|
|
|
15
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
19
|
const __dirname = path.dirname(__filename);
|
|
@@ -38,7 +41,7 @@ global.fetch = async (url, options) => {
|
|
|
38
41
|
const server = new Server(
|
|
39
42
|
{
|
|
40
43
|
name: "scad-mcp-server",
|
|
41
|
-
version: "1.
|
|
44
|
+
version: "1.3.0",
|
|
42
45
|
},
|
|
43
46
|
{
|
|
44
47
|
capabilities: {
|
|
@@ -62,6 +65,88 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
62
65
|
type: "string",
|
|
63
66
|
description: "The description of the object you want to design.",
|
|
64
67
|
},
|
|
68
|
+
options: {
|
|
69
|
+
type: "object",
|
|
70
|
+
description:
|
|
71
|
+
"Optional feature toggles to customize the generated prompt syntax rules.",
|
|
72
|
+
properties: {
|
|
73
|
+
basic: {
|
|
74
|
+
type: "boolean",
|
|
75
|
+
description:
|
|
76
|
+
"Include rules for basic PBR attributes: metalness and roughness. (Default: true)",
|
|
77
|
+
},
|
|
78
|
+
transmission: {
|
|
79
|
+
type: "boolean",
|
|
80
|
+
description:
|
|
81
|
+
"Include rules for transparent/glass volumes: transmission, thickness, ior, attenuationColor, and attenuationDistance. (Default: true)",
|
|
82
|
+
},
|
|
83
|
+
clearcoat: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
description:
|
|
86
|
+
"Include rules for clearcoat and clearcoatRoughness. (Default: true)",
|
|
87
|
+
},
|
|
88
|
+
sheen: {
|
|
89
|
+
type: "boolean",
|
|
90
|
+
description:
|
|
91
|
+
"Include rules for cloth/velvet sheen: sheen, sheenColor, and sheenRoughness. (Default: true)",
|
|
92
|
+
},
|
|
93
|
+
emissive: {
|
|
94
|
+
type: "boolean",
|
|
95
|
+
description:
|
|
96
|
+
"Include rules for glowing materials: emissive and emissiveIntensity. (Default: true)",
|
|
97
|
+
},
|
|
98
|
+
specular: {
|
|
99
|
+
type: "boolean",
|
|
100
|
+
description:
|
|
101
|
+
"Include rules for specular reflections: specularColor and specularIntensity. (Default: true)",
|
|
102
|
+
},
|
|
103
|
+
iridescence: {
|
|
104
|
+
type: "boolean",
|
|
105
|
+
description:
|
|
106
|
+
"Include rules for thin-film interference: iridescence and iridescenceIOR. (Default: true)",
|
|
107
|
+
},
|
|
108
|
+
autoSmoothAngle: {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
description:
|
|
111
|
+
"Include rules for smooth shading vertex normals via $asa. (Default: true)",
|
|
112
|
+
},
|
|
113
|
+
animation: {
|
|
114
|
+
type: "boolean",
|
|
115
|
+
description:
|
|
116
|
+
"Include rules for hierarchical node animations using armature() and bone(). (Default: true)",
|
|
117
|
+
},
|
|
118
|
+
bakeColors: {
|
|
119
|
+
type: "boolean",
|
|
120
|
+
description:
|
|
121
|
+
"Include rules for baking colors from high-poly onto low-poly meshes. (Default: false)",
|
|
122
|
+
},
|
|
123
|
+
bakeNormals: {
|
|
124
|
+
type: "boolean",
|
|
125
|
+
description:
|
|
126
|
+
"Include rules for baking tangent-space normal maps. (Default: false)",
|
|
127
|
+
},
|
|
128
|
+
bakeOrm: {
|
|
129
|
+
type: "boolean",
|
|
130
|
+
description:
|
|
131
|
+
"Include rules for baking Occlusion/Roughness/Metallic (ORM) textures. (Default: false)",
|
|
132
|
+
},
|
|
133
|
+
bakeUvs: {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
description:
|
|
136
|
+
"Include rules for generating textureless UV coordinates and tangents. (Default: false)",
|
|
137
|
+
},
|
|
138
|
+
lazyUnion: {
|
|
139
|
+
type: "boolean",
|
|
140
|
+
description:
|
|
141
|
+
"Include rules for the lazy-union compiler optimization. (Default: false)",
|
|
142
|
+
},
|
|
143
|
+
modelName: {
|
|
144
|
+
type: "boolean",
|
|
145
|
+
description:
|
|
146
|
+
"Include output instructions requiring the '/* Model Name: ... */' header comment. (Default: true)",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
65
150
|
},
|
|
66
151
|
required: ["description"],
|
|
67
152
|
},
|
|
@@ -104,10 +189,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
104
189
|
description:
|
|
105
190
|
"The index of the animation track to play if multiple exist. Default is 0.",
|
|
106
191
|
},
|
|
192
|
+
return_images: {
|
|
193
|
+
type: "boolean",
|
|
194
|
+
description:
|
|
195
|
+
"Return image renderings of the model. Set to false for text-only validation (faster). Default is true.",
|
|
196
|
+
},
|
|
107
197
|
},
|
|
108
198
|
required: ["scad_code"],
|
|
109
199
|
},
|
|
110
200
|
},
|
|
201
|
+
{
|
|
202
|
+
name: "test_godot_project",
|
|
203
|
+
description:
|
|
204
|
+
"Runs a specified Godot project for a short duration to detect script errors, missing resources, or runtime crashes. Returns the console output logs and a visual screenshot of the gameplay.",
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: {
|
|
208
|
+
project_dir: {
|
|
209
|
+
type: "string",
|
|
210
|
+
description:
|
|
211
|
+
"The relative or absolute path to the directory containing project.godot. Use this if the project is already extracted on disk.",
|
|
212
|
+
},
|
|
213
|
+
nodejs_script: {
|
|
214
|
+
type: "string",
|
|
215
|
+
description:
|
|
216
|
+
"The complete self-contained Node.js script that generates the Godot project. If provided, the tool will execute this script in a temporary directory to extract the project before testing it. Use this during the generation phase to test your code before providing the final answer.",
|
|
217
|
+
},
|
|
218
|
+
run_time: {
|
|
219
|
+
type: "number",
|
|
220
|
+
description:
|
|
221
|
+
"Time in seconds to run the project before terminating. Default is 5.0.",
|
|
222
|
+
},
|
|
223
|
+
return_images: {
|
|
224
|
+
type: "boolean",
|
|
225
|
+
description:
|
|
226
|
+
"Capture a screenshot of the project. Set to false for text-only logs (faster). Default is true.",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "compile_bevy_project",
|
|
233
|
+
description:
|
|
234
|
+
"Compiles a specified Rust Bevy project to detect syntax errors, missing dependencies, or compilation failures. Returns the cargo console output logs.",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: {
|
|
238
|
+
project_dir: {
|
|
239
|
+
type: "string",
|
|
240
|
+
description:
|
|
241
|
+
"The relative or absolute path to the directory containing Cargo.toml. Use this if the project is already extracted on disk.",
|
|
242
|
+
},
|
|
243
|
+
nodejs_script: {
|
|
244
|
+
type: "string",
|
|
245
|
+
description:
|
|
246
|
+
"The complete self-contained Node.js script that generates the Bevy project. If provided, the tool will execute this script in a temporary directory to extract the project before compiling it. Use this during the generation phase to test your code before providing the final answer.",
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
111
251
|
],
|
|
112
252
|
};
|
|
113
253
|
});
|
|
@@ -121,7 +261,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
121
261
|
// ------------------------------------------
|
|
122
262
|
if (name === "get_scad_prompt") {
|
|
123
263
|
try {
|
|
124
|
-
const promptText = generatePrompt(args.description, {});
|
|
264
|
+
const promptText = generatePrompt(args.description, args.options || {});
|
|
125
265
|
return {
|
|
126
266
|
content: [
|
|
127
267
|
{
|
|
@@ -153,12 +293,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
153
293
|
: ["front", "top", "isometric"];
|
|
154
294
|
const animTime = args.animation_time || 0.0;
|
|
155
295
|
const animIndex = args.animation_index || 0;
|
|
296
|
+
const returnImages = args.return_images !== false;
|
|
156
297
|
|
|
157
298
|
// 1. Convert SCAD to GLB ArrayBuffer
|
|
158
299
|
const glbDataArray = await convertScadToGltf(scadCode, {
|
|
159
300
|
wasmUrl: `file://${wasmPath}`,
|
|
160
301
|
});
|
|
161
302
|
|
|
303
|
+
if (!returnImages) {
|
|
304
|
+
return {
|
|
305
|
+
content: [
|
|
306
|
+
{
|
|
307
|
+
type: "text",
|
|
308
|
+
text: `Successfully compiled SCAD without syntax errors. (Visual rendering skipped because return_images is false).`,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
162
314
|
// Convert Uint8Array to base64
|
|
163
315
|
const glbBase64 = Buffer.from(glbDataArray).toString("base64");
|
|
164
316
|
|
|
@@ -492,6 +644,493 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
492
644
|
}
|
|
493
645
|
}
|
|
494
646
|
|
|
647
|
+
// ------------------------------------------
|
|
648
|
+
// TOOL 3: test_godot_project
|
|
649
|
+
// ------------------------------------------
|
|
650
|
+
if (name === "test_godot_project") {
|
|
651
|
+
let cleanupDir = null;
|
|
652
|
+
try {
|
|
653
|
+
let projectDir = null;
|
|
654
|
+
const runTime = args.run_time || 5.0;
|
|
655
|
+
|
|
656
|
+
if (args.nodejs_script) {
|
|
657
|
+
// Create a unique temporary directory
|
|
658
|
+
const tempBase = fs.mkdtempSync(
|
|
659
|
+
path.join(os.tmpdir(), "scad-godot-test-"),
|
|
660
|
+
);
|
|
661
|
+
cleanupDir = tempBase;
|
|
662
|
+
|
|
663
|
+
const scriptPath = path.join(tempBase, "generate.js");
|
|
664
|
+
fs.writeFileSync(scriptPath, args.nodejs_script, "utf-8");
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
execSync(`node generate.js`, {
|
|
668
|
+
cwd: tempBase,
|
|
669
|
+
stdio: "pipe",
|
|
670
|
+
timeout: 15000, // Time out after 15 seconds if it hangs
|
|
671
|
+
});
|
|
672
|
+
} catch (err) {
|
|
673
|
+
const stderr = err.stderr ? err.stderr.toString() : "";
|
|
674
|
+
const stdout = err.stdout ? err.stdout.toString() : "";
|
|
675
|
+
return {
|
|
676
|
+
content: [
|
|
677
|
+
{
|
|
678
|
+
type: "text",
|
|
679
|
+
text: `Error: The Node.js script failed to execute properly.\n\nSTDERR:\n${stderr}\n\nSTDOUT:\n${stdout}\n\nException: ${err.message}`,
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
isError: true,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Find the generated folder containing project.godot
|
|
687
|
+
const items = fs.readdirSync(tempBase);
|
|
688
|
+
for (const item of items) {
|
|
689
|
+
const itemPath = path.join(tempBase, item);
|
|
690
|
+
if (
|
|
691
|
+
fs.statSync(itemPath).isDirectory() &&
|
|
692
|
+
fs.existsSync(path.join(itemPath, "project.godot"))
|
|
693
|
+
) {
|
|
694
|
+
projectDir = itemPath;
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!projectDir) {
|
|
700
|
+
return {
|
|
701
|
+
content: [
|
|
702
|
+
{
|
|
703
|
+
type: "text",
|
|
704
|
+
text: `Error: The Node.js script finished running, but NO Godot project was found. A valid Godot project requires a 'project.godot' file. Ensure your script creates a root project directory and places 'project.godot' inside it.`,
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
isError: true,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
} else if (args.project_dir) {
|
|
711
|
+
projectDir = path.resolve(process.cwd(), args.project_dir);
|
|
712
|
+
} else {
|
|
713
|
+
return {
|
|
714
|
+
content: [
|
|
715
|
+
{
|
|
716
|
+
type: "text",
|
|
717
|
+
text: `Error: Invalid arguments. You must provide either 'project_dir' or 'nodejs_script'.`,
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
isError: true,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!fs.existsSync(path.join(projectDir, "project.godot"))) {
|
|
725
|
+
return {
|
|
726
|
+
content: [
|
|
727
|
+
{
|
|
728
|
+
type: "text",
|
|
729
|
+
text: `Error: The specified directory is not a valid Godot project. 'project.godot' was not found in: ${projectDir}`,
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
isError: true,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const returnImages = args.return_images !== false;
|
|
737
|
+
|
|
738
|
+
// 1. Headless Editor Import Step (Wait for Godot to import .scad and other assets)
|
|
739
|
+
const importResult = await runGodotAsync(
|
|
740
|
+
["--headless", "--editor", "--quit", "--path", projectDir],
|
|
741
|
+
projectDir,
|
|
742
|
+
30000,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
const projectGodotPath = path.join(projectDir, "project.godot");
|
|
746
|
+
const originalProjectGodot = fs.readFileSync(projectGodotPath, "utf8");
|
|
747
|
+
let playResult;
|
|
748
|
+
|
|
749
|
+
if (returnImages) {
|
|
750
|
+
// 1.5 Prepare Screenshot Autoload Script
|
|
751
|
+
const snapTime = Math.max(0.5, runTime - 0.5);
|
|
752
|
+
|
|
753
|
+
const screenshotScript = `extends Node
|
|
754
|
+
func _ready():
|
|
755
|
+
\tprint("[ScreenshotMCP] Autoload ready. Waiting ${snapTime} seconds...")
|
|
756
|
+
\tawait get_tree().create_timer(${snapTime}).timeout
|
|
757
|
+
\tvar ds = DisplayServer.get_name()
|
|
758
|
+
\tprint("[ScreenshotMCP] DisplayServer is: ", ds)
|
|
759
|
+
\tif ds != "headless":
|
|
760
|
+
\t\tawait RenderingServer.frame_post_draw
|
|
761
|
+
\t\tvar tex = get_viewport().get_texture()
|
|
762
|
+
\t\tif tex != null:
|
|
763
|
+
\t\t\tvar img = tex.get_image()
|
|
764
|
+
\t\t\tif img != null and not img.is_empty():
|
|
765
|
+
\t\t\t\tvar w = img.get_width()
|
|
766
|
+
\t\t\t\tvar h = img.get_height()
|
|
767
|
+
\t\t\t\tvar size = mini(w, h)
|
|
768
|
+
\t\t\t\tvar x = (w - size) / 2
|
|
769
|
+
\t\t\t\tvar y = (h - size) / 2
|
|
770
|
+
\t\t\t\tvar cropped = img.get_region(Rect2i(x, y, size, size))
|
|
771
|
+
\t\t\t\tvar path = ProjectSettings.globalize_path("res://screenshot_mcp.png")
|
|
772
|
+
\t\t\t\tvar err = cropped.save_png(path)
|
|
773
|
+
\t\t\t\tprint("[ScreenshotMCP] Save PNG to ", path, " returned error code: ", err)
|
|
774
|
+
\t\t\telse:
|
|
775
|
+
\t\t\t\tprint("[ScreenshotMCP] Error: Image is null or empty.")
|
|
776
|
+
\t\telse:
|
|
777
|
+
\t\t\tprint("[ScreenshotMCP] Error: Viewport texture is null.")
|
|
778
|
+
\tget_tree().quit()
|
|
779
|
+
`;
|
|
780
|
+
fs.writeFileSync(
|
|
781
|
+
path.join(projectDir, "screenshot_mcp.gd"),
|
|
782
|
+
screenshotScript,
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
let tempProjectGodot = originalProjectGodot;
|
|
786
|
+
if (tempProjectGodot.includes("[autoload]")) {
|
|
787
|
+
tempProjectGodot = tempProjectGodot.replace(
|
|
788
|
+
"[autoload]",
|
|
789
|
+
'[autoload]\nScreenshotMCP="*res://screenshot_mcp.gd"',
|
|
790
|
+
);
|
|
791
|
+
} else {
|
|
792
|
+
tempProjectGodot += `\n[autoload]\nScreenshotMCP="*res://screenshot_mcp.gd"\n`;
|
|
793
|
+
}
|
|
794
|
+
fs.writeFileSync(projectGodotPath, tempProjectGodot, "utf8");
|
|
795
|
+
|
|
796
|
+
// 2. Play Game Step
|
|
797
|
+
// Always try windowed first to get the screenshot.
|
|
798
|
+
// If the environment lacks a display server, we'll catch the error and fallback.
|
|
799
|
+
let playArgs = [
|
|
800
|
+
"--windowed", // Use windowed instead of headless to ensure rendering works for screenshot
|
|
801
|
+
"--resolution",
|
|
802
|
+
"512x512",
|
|
803
|
+
"--audio-driver",
|
|
804
|
+
"Dummy",
|
|
805
|
+
"--path",
|
|
806
|
+
projectDir,
|
|
807
|
+
];
|
|
808
|
+
|
|
809
|
+
playResult = await runGodotAsync(
|
|
810
|
+
playArgs,
|
|
811
|
+
projectDir,
|
|
812
|
+
// Give Godot extra buffer time to boot, take the shot, and quit gracefully
|
|
813
|
+
runTime * 1000 + 5000,
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
let windowedOutput = playResult.output;
|
|
817
|
+
|
|
818
|
+
// Fallback if windowed mode fails due to display server issues
|
|
819
|
+
if (
|
|
820
|
+
playResult.code !== 0 &&
|
|
821
|
+
(playResult.output.includes("Unable to create DisplayServer") ||
|
|
822
|
+
playResult.output.includes("Display driver"))
|
|
823
|
+
) {
|
|
824
|
+
playResult = await runGodotAsync(
|
|
825
|
+
["--headless", "--audio-driver", "Dummy", "--path", projectDir],
|
|
826
|
+
projectDir,
|
|
827
|
+
runTime * 1000,
|
|
828
|
+
);
|
|
829
|
+
// Explicitly inject the display error into the final output so we aren't flying blind!
|
|
830
|
+
playResult.output =
|
|
831
|
+
"--- WINDOWED LAUNCH FAILED (Display Error) ---\n" +
|
|
832
|
+
windowedOutput.trim() +
|
|
833
|
+
"\n\n--- FALLBACK HEADLESS LAUNCH ---\n" +
|
|
834
|
+
playResult.output;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 3. Cleanup Autoloads
|
|
838
|
+
fs.writeFileSync(projectGodotPath, originalProjectGodot, "utf8");
|
|
839
|
+
if (fs.existsSync(path.join(projectDir, "screenshot_mcp.gd"))) {
|
|
840
|
+
fs.unlinkSync(path.join(projectDir, "screenshot_mcp.gd"));
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
// Text-only mode: Run headless immediately
|
|
844
|
+
playResult = await runGodotAsync(
|
|
845
|
+
["--headless", "--audio-driver", "Dummy", "--path", projectDir],
|
|
846
|
+
projectDir,
|
|
847
|
+
runTime * 1000,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// 4. Read Screenshot
|
|
852
|
+
let screenshotBase64 = null;
|
|
853
|
+
if (returnImages) {
|
|
854
|
+
const screenshotPath = path.join(projectDir, "screenshot_mcp.png");
|
|
855
|
+
if (fs.existsSync(screenshotPath)) {
|
|
856
|
+
screenshotBase64 = fs.readFileSync(screenshotPath).toString("base64");
|
|
857
|
+
fs.unlinkSync(screenshotPath); // Remove the image after reading
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Format Text Output
|
|
862
|
+
const fullOutput =
|
|
863
|
+
"--- IMPORT PHASE ---\n" +
|
|
864
|
+
importResult.output +
|
|
865
|
+
"\n\n--- PLAY PHASE ---\n" +
|
|
866
|
+
playResult.output;
|
|
867
|
+
|
|
868
|
+
const errorLines = fullOutput
|
|
869
|
+
.split("\n")
|
|
870
|
+
.filter(
|
|
871
|
+
(line) =>
|
|
872
|
+
line.includes("ERROR:") ||
|
|
873
|
+
line.includes("SCRIPT ERROR:") ||
|
|
874
|
+
line.includes("Parse Error:"),
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
let responseText = `Godot Import Exit Code: ${importResult.code}\nGodot Play Exit Code: ${playResult.code}\n\n`;
|
|
878
|
+
|
|
879
|
+
if (errorLines.length > 0) {
|
|
880
|
+
responseText +=
|
|
881
|
+
"🚨 Errors detected in Godot output:\n" +
|
|
882
|
+
errorLines.join("\n") +
|
|
883
|
+
"\n\n";
|
|
884
|
+
} else {
|
|
885
|
+
responseText +=
|
|
886
|
+
"✅ No obvious errors detected in the Godot output log.\n\n";
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const maxOutputLen = 4000;
|
|
890
|
+
let truncatedOutput = fullOutput;
|
|
891
|
+
if (fullOutput.length > maxOutputLen) {
|
|
892
|
+
truncatedOutput = fullOutput.substring(
|
|
893
|
+
fullOutput.length - maxOutputLen,
|
|
894
|
+
);
|
|
895
|
+
responseText += `...[truncated]...\n`;
|
|
896
|
+
}
|
|
897
|
+
responseText += truncatedOutput;
|
|
898
|
+
|
|
899
|
+
// 5. Construct Final MCP Response
|
|
900
|
+
const responseContent = [
|
|
901
|
+
{
|
|
902
|
+
type: "text",
|
|
903
|
+
text: responseText,
|
|
904
|
+
},
|
|
905
|
+
];
|
|
906
|
+
|
|
907
|
+
// If screenshot was captured successfully, attach it
|
|
908
|
+
if (returnImages) {
|
|
909
|
+
if (screenshotBase64) {
|
|
910
|
+
responseContent.push({
|
|
911
|
+
type: "image",
|
|
912
|
+
data: screenshotBase64,
|
|
913
|
+
mimeType: "image/png",
|
|
914
|
+
});
|
|
915
|
+
} else {
|
|
916
|
+
responseContent.push({
|
|
917
|
+
type: "text",
|
|
918
|
+
text: "⚠️ A visual screenshot could not be captured (Godot may have crashed immediately or rendering failed).",
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return {
|
|
924
|
+
content: responseContent,
|
|
925
|
+
};
|
|
926
|
+
} catch (error) {
|
|
927
|
+
return {
|
|
928
|
+
content: [
|
|
929
|
+
{
|
|
930
|
+
type: "text",
|
|
931
|
+
text: `Error testing Godot project: ${error.message}`,
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
isError: true,
|
|
935
|
+
};
|
|
936
|
+
} finally {
|
|
937
|
+
// Clean up temporary directory if we created one
|
|
938
|
+
if (cleanupDir && fs.existsSync(cleanupDir)) {
|
|
939
|
+
try {
|
|
940
|
+
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
941
|
+
} catch (e) {
|
|
942
|
+
console.error(`Failed to clean up temp dir: ${cleanupDir}`, e);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ------------------------------------------
|
|
949
|
+
// TOOL 4: compile_bevy_project
|
|
950
|
+
// ------------------------------------------
|
|
951
|
+
if (name === "compile_bevy_project") {
|
|
952
|
+
let cleanupDir = null;
|
|
953
|
+
try {
|
|
954
|
+
let projectDir = null;
|
|
955
|
+
|
|
956
|
+
if (args.nodejs_script) {
|
|
957
|
+
// Create a unique temporary directory
|
|
958
|
+
const tempBase = fs.mkdtempSync(
|
|
959
|
+
path.join(os.tmpdir(), "scad-bevy-compile-"),
|
|
960
|
+
);
|
|
961
|
+
cleanupDir = tempBase;
|
|
962
|
+
|
|
963
|
+
const scriptPath = path.join(tempBase, "generate.js");
|
|
964
|
+
fs.writeFileSync(scriptPath, args.nodejs_script, "utf-8");
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
execSync(`node generate.js`, {
|
|
968
|
+
cwd: tempBase,
|
|
969
|
+
stdio: "pipe",
|
|
970
|
+
timeout: 15000, // Time out after 15 seconds if it hangs
|
|
971
|
+
});
|
|
972
|
+
} catch (err) {
|
|
973
|
+
const stderr = err.stderr ? err.stderr.toString() : "";
|
|
974
|
+
const stdout = err.stdout ? err.stdout.toString() : "";
|
|
975
|
+
return {
|
|
976
|
+
content: [
|
|
977
|
+
{
|
|
978
|
+
type: "text",
|
|
979
|
+
text: `Error: The Node.js script failed to execute properly.\n\nSTDERR:\n${stderr}\n\nSTDOUT:\n${stdout}\n\nException: ${err.message}`,
|
|
980
|
+
},
|
|
981
|
+
],
|
|
982
|
+
isError: true,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Find the generated folder containing Cargo.toml
|
|
987
|
+
const items = fs.readdirSync(tempBase);
|
|
988
|
+
for (const item of items) {
|
|
989
|
+
const itemPath = path.join(tempBase, item);
|
|
990
|
+
if (
|
|
991
|
+
fs.statSync(itemPath).isDirectory() &&
|
|
992
|
+
fs.existsSync(path.join(itemPath, "Cargo.toml"))
|
|
993
|
+
) {
|
|
994
|
+
projectDir = itemPath;
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (!projectDir) {
|
|
1000
|
+
return {
|
|
1001
|
+
content: [
|
|
1002
|
+
{
|
|
1003
|
+
type: "text",
|
|
1004
|
+
text: `Error: The Node.js script finished running, but NO Bevy project was found. A valid Rust Bevy project requires a 'Cargo.toml' file. Ensure your script creates a root project directory and places 'Cargo.toml' inside it.`,
|
|
1005
|
+
},
|
|
1006
|
+
],
|
|
1007
|
+
isError: true,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
} else if (args.project_dir) {
|
|
1011
|
+
projectDir = path.resolve(process.cwd(), args.project_dir);
|
|
1012
|
+
} else {
|
|
1013
|
+
return {
|
|
1014
|
+
content: [
|
|
1015
|
+
{
|
|
1016
|
+
type: "text",
|
|
1017
|
+
text: `Error: Invalid arguments. You must provide either 'project_dir' or 'nodejs_script'.`,
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
isError: true,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (!fs.existsSync(path.join(projectDir, "Cargo.toml"))) {
|
|
1025
|
+
return {
|
|
1026
|
+
content: [
|
|
1027
|
+
{
|
|
1028
|
+
type: "text",
|
|
1029
|
+
text: `Error: The specified directory is not a valid Rust Bevy project. 'Cargo.toml' was not found in: ${projectDir}`,
|
|
1030
|
+
},
|
|
1031
|
+
],
|
|
1032
|
+
isError: true,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const runCargoAsync = () =>
|
|
1037
|
+
new Promise((resolve) => {
|
|
1038
|
+
const cargoCmd = process.platform === "win32" ? "cargo.exe" : "cargo";
|
|
1039
|
+
const child = spawn(cargoCmd, ["check"], {
|
|
1040
|
+
cwd: projectDir,
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
let output = "";
|
|
1044
|
+
if (child.stdout)
|
|
1045
|
+
child.stdout.on("data", (data) => (output += data.toString()));
|
|
1046
|
+
if (child.stderr)
|
|
1047
|
+
child.stderr.on("data", (data) => (output += data.toString()));
|
|
1048
|
+
|
|
1049
|
+
child.on("error", (error) => {
|
|
1050
|
+
output += `\nError launching cargo: ${error.message}`;
|
|
1051
|
+
resolve({ code: 1, output });
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
child.on("close", (code) => {
|
|
1055
|
+
resolve({ code, output });
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// 2-minute timeout for Bevy compilation (can be slow as dependencies download)
|
|
1059
|
+
setTimeout(() => {
|
|
1060
|
+
try {
|
|
1061
|
+
child.kill();
|
|
1062
|
+
} catch (e) {}
|
|
1063
|
+
output += "\n--- COMPILATION TIMED OUT (2 minutes) ---";
|
|
1064
|
+
resolve({ code: 1, output });
|
|
1065
|
+
}, 120000);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
const compileResult = await runCargoAsync();
|
|
1069
|
+
const compileOutput = compileResult.output;
|
|
1070
|
+
const compileCode = compileResult.code;
|
|
1071
|
+
|
|
1072
|
+
const fullOutput = "--- CARGO CHECK ---\n" + compileOutput.trim();
|
|
1073
|
+
|
|
1074
|
+
const errorLines = fullOutput
|
|
1075
|
+
.split("\n")
|
|
1076
|
+
.filter(
|
|
1077
|
+
(line) =>
|
|
1078
|
+
line.includes("error:") ||
|
|
1079
|
+
line.includes("error[E") ||
|
|
1080
|
+
line.includes("could not compile"),
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
let responseText = `Cargo Check Exit Code: ${compileCode}\n\n`;
|
|
1084
|
+
|
|
1085
|
+
if (compileCode !== 0 || errorLines.length > 0) {
|
|
1086
|
+
responseText +=
|
|
1087
|
+
"🚨 Errors detected in Cargo output:\n" +
|
|
1088
|
+
errorLines.join("\n") +
|
|
1089
|
+
"\n\n";
|
|
1090
|
+
} else {
|
|
1091
|
+
responseText +=
|
|
1092
|
+
"✅ No obvious errors detected. Project checked successfully.\n\n";
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const maxOutputLen = 4000;
|
|
1096
|
+
let truncatedOutput = fullOutput;
|
|
1097
|
+
if (fullOutput.length > maxOutputLen) {
|
|
1098
|
+
truncatedOutput = fullOutput.substring(
|
|
1099
|
+
fullOutput.length - maxOutputLen,
|
|
1100
|
+
);
|
|
1101
|
+
responseText += `...[truncated]...\n`;
|
|
1102
|
+
}
|
|
1103
|
+
responseText += truncatedOutput;
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
content: [
|
|
1107
|
+
{
|
|
1108
|
+
type: "text",
|
|
1109
|
+
text: responseText,
|
|
1110
|
+
},
|
|
1111
|
+
],
|
|
1112
|
+
};
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
return {
|
|
1115
|
+
content: [
|
|
1116
|
+
{
|
|
1117
|
+
type: "text",
|
|
1118
|
+
text: `Error compiling Bevy project: ${error.message}`,
|
|
1119
|
+
},
|
|
1120
|
+
],
|
|
1121
|
+
isError: true,
|
|
1122
|
+
};
|
|
1123
|
+
} finally {
|
|
1124
|
+
if (cleanupDir && fs.existsSync(cleanupDir)) {
|
|
1125
|
+
try {
|
|
1126
|
+
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
console.error(`Failed to clean up temp dir: ${cleanupDir}`, e);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
495
1134
|
throw new Error(`Tool not found: ${name}`);
|
|
496
1135
|
});
|
|
497
1136
|
|