@threenative/core 0.2.0 → 0.3.1

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/capabilities.json +5292 -0
  4. package/dist/assets-kyoF7JlJ.d.ts +103 -0
  5. package/dist/audio-BFiGneTL.d.ts +156 -0
  6. package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
  7. package/dist/game-XGrTzapq.d.ts +1166 -0
  8. package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
  9. package/dist/hot.d.ts +20 -2
  10. package/dist/hot.js +14 -2
  11. package/dist/index.d.ts +2108 -55
  12. package/dist/index.js +15645 -2222
  13. package/dist/net.d.ts +65 -0
  14. package/dist/net.js +643 -0
  15. package/dist/playtest.d.ts +37 -4
  16. package/dist/playtest.js +246 -546
  17. package/dist/react.d.ts +177 -0
  18. package/dist/react.js +635 -0
  19. package/dist/renderer-C6hqZpoG.d.ts +770 -0
  20. package/dist/ui-layer.d.ts +306 -0
  21. package/dist/ui-layer.js +425 -0
  22. package/dist/world.d.ts +254 -0
  23. package/dist/world.js +2686 -0
  24. package/gpl/LICENSE.GPL +117 -0
  25. package/gpl/convert.py +192 -0
  26. package/gpl/recipes/_common.py +169 -0
  27. package/gpl/recipes/bake_ao.py +111 -0
  28. package/gpl/recipes/decimate.py +64 -0
  29. package/gpl/recipes/retarget.py +131 -0
  30. package/gpl/recipes/unwrap.py +71 -0
  31. package/mcp/assets.mjs +5 -0
  32. package/mcp/blender-server.mjs +632 -0
  33. package/mcp/blender.mjs +27 -0
  34. package/mcp/engine-server.mjs +501 -0
  35. package/mcp/engine.mjs +31 -0
  36. package/mcp/install.d.mts +37 -0
  37. package/mcp/install.mjs +145 -0
  38. package/mcp/launch.mjs +72 -0
  39. package/mcp/sculpt.mjs +5 -0
  40. package/mcp/servers.d.mts +34 -0
  41. package/mcp/servers.mjs +160 -0
  42. package/package.json +76 -6
  43. package/patches/three@0.185.1.patch +522 -0
  44. package/scripts/apply-three-patch.mjs +297 -0
  45. package/scripts/ensure-mcp.mjs +43 -0
  46. package/scripts/postinstall.mjs +6 -0
  47. package/dist/audio-CEAw0w5y.d.ts +0 -35
  48. package/dist/game-DRt1Qhq3.d.ts +0 -429
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync, existsSync, readFileSync, statSync, accessSync, constants } from 'fs';
3
+ import path2 from 'path';
4
+ import { createInterface } from 'readline';
5
+ import { fileURLToPath } from 'url';
6
+ import { execFileSync, execFile } from 'child_process';
7
+ import { mkdtemp, rm } from 'fs/promises';
8
+ import os, { platform, homedir } from 'os';
9
+
10
+ var BLENDER_VERSION_FLOOR = Object.freeze({ major: 4, minor: 2 });
11
+ var BLENDER_INSTALL_GUIDANCE = Object.freeze({
12
+ linux: "sudo snap install blender --classic (or your distribution's package, or https://www.blender.org/download/)",
13
+ macos: "brew install --cask blender (or https://www.blender.org/download/)",
14
+ windows: "winget install --id BlenderFoundation.Blender (or https://www.blender.org/download/)"
15
+ });
16
+ function floorString() {
17
+ return `${BLENDER_VERSION_FLOOR.major}.${BLENDER_VERSION_FLOOR.minor}`;
18
+ }
19
+ function installCommandFor(platformName = platform()) {
20
+ if (platformName === "darwin") return BLENDER_INSTALL_GUIDANCE.macos;
21
+ if (platformName === "win32") return BLENDER_INSTALL_GUIDANCE.windows;
22
+ return BLENDER_INSTALL_GUIDANCE.linux;
23
+ }
24
+ function executableFile(candidate) {
25
+ try {
26
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) return false;
27
+ accessSync(candidate, constants.X_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function binaryNames(platformName) {
34
+ return platformName === "win32" ? ["blender.exe", "blender.com"] : ["blender"];
35
+ }
36
+ function pathCandidates(environment, platformName) {
37
+ const raw = environment.PATH ?? environment.Path ?? "";
38
+ if (raw.trim().length === 0) return [];
39
+ return raw.split(path2.delimiter).filter((directory) => directory.trim().length > 0).flatMap((directory) => binaryNames(platformName).map((name) => path2.join(directory, name)));
40
+ }
41
+ function conventionalCandidates(platformName, home) {
42
+ if (platformName === "darwin") {
43
+ return [
44
+ "/Applications/Blender.app/Contents/MacOS/Blender",
45
+ path2.join(home, "Applications/Blender.app/Contents/MacOS/Blender")
46
+ ];
47
+ }
48
+ if (platformName === "win32") {
49
+ const roots = [
50
+ process.env.ProgramFiles ?? "C:\\Program Files",
51
+ process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"
52
+ ];
53
+ return roots.flatMap(
54
+ (root) => ["5.2", "5.1", "5.0", "4.5", "4.2"].map(
55
+ (release) => path2.join(root, "Blender Foundation", `Blender ${release}`, "blender.exe")
56
+ )
57
+ );
58
+ }
59
+ return [
60
+ "/usr/bin/blender",
61
+ "/usr/local/bin/blender",
62
+ "/snap/bin/blender",
63
+ "/var/lib/flatpak/exports/bin/org.blender.Blender",
64
+ path2.join(home, ".local/bin/blender")
65
+ ];
66
+ }
67
+ function parseBlenderVersion(output) {
68
+ const match = /^Blender\s+(\d+\.\d+(?:\.\d+)?)/mu.exec(output);
69
+ return match?.[1];
70
+ }
71
+ function compareToFloor(version) {
72
+ const [major = 0, minor = 0] = version.split(".").map((part) => Number.parseInt(part, 10));
73
+ if (Number.isNaN(major) || Number.isNaN(minor)) return false;
74
+ if (major !== BLENDER_VERSION_FLOOR.major) return major > BLENDER_VERSION_FLOOR.major;
75
+ return minor >= BLENDER_VERSION_FLOOR.minor;
76
+ }
77
+ var probeBlenderVersion = (binary) => {
78
+ try {
79
+ const output = execFileSync(binary, ["--version"], {
80
+ encoding: "utf8",
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ timeout: 3e4
83
+ });
84
+ return parseBlenderVersion(output);
85
+ } catch {
86
+ return void 0;
87
+ }
88
+ };
89
+ function resolveBlender(environment = process.env, options = {}) {
90
+ const platformName = options.platform ?? platform();
91
+ const home = options.home ?? environment.HOME ?? environment.USERPROFILE ?? homedir();
92
+ const probeVersion = options.probeVersion ?? probeBlenderVersion;
93
+ const minimumVersion = floorString();
94
+ const install = BLENDER_INSTALL_GUIDANCE;
95
+ const override = environment.THREENATIVE_BLENDER_PATH;
96
+ if (override !== void 0 && override.trim().length > 0) {
97
+ const candidate = path2.resolve(override.trim());
98
+ if (!executableFile(candidate)) {
99
+ return {
100
+ available: false,
101
+ cause: "blender-unreadable",
102
+ detail: `THREENATIVE_BLENDER_PATH points at '${candidate}', which is not an executable file.`,
103
+ install,
104
+ minimumVersion,
105
+ path: candidate
106
+ };
107
+ }
108
+ return classify(candidate, probeVersion, minimumVersion, install);
109
+ }
110
+ const candidates = [
111
+ ...pathCandidates(environment, platformName),
112
+ ...conventionalCandidates(platformName, home)
113
+ ];
114
+ for (const candidate of candidates) {
115
+ if (!executableFile(candidate)) continue;
116
+ const classified = classify(candidate, probeVersion, minimumVersion, install);
117
+ if (classified.cause !== "blender-unreadable") return classified;
118
+ }
119
+ return {
120
+ available: false,
121
+ cause: "blender-missing",
122
+ detail: `No Blender ${minimumVersion} or newer was found. Set THREENATIVE_BLENDER_PATH, or install it: ${installCommandFor(platformName)}`,
123
+ install,
124
+ minimumVersion
125
+ };
126
+ }
127
+ function classify(binary, probeVersion, minimumVersion, install) {
128
+ const version = probeVersion(binary);
129
+ if (version === void 0) {
130
+ return {
131
+ available: false,
132
+ cause: "blender-unreadable",
133
+ detail: `'${binary}' did not report a Blender version.`,
134
+ install,
135
+ minimumVersion,
136
+ path: binary
137
+ };
138
+ }
139
+ if (!compareToFloor(version)) {
140
+ return {
141
+ available: false,
142
+ cause: "blender-too-old",
143
+ detail: `Blender ${version} at '${binary}' is older than the supported floor ${minimumVersion}.`,
144
+ install,
145
+ minimumVersion,
146
+ path: binary,
147
+ version
148
+ };
149
+ }
150
+ return {
151
+ available: true,
152
+ detail: `Blender ${version} at '${binary}'.`,
153
+ install,
154
+ minimumVersion,
155
+ path: binary,
156
+ version
157
+ };
158
+ }
159
+
160
+ // src/bridge.ts
161
+ var BLENDER_SOURCE_EXTENSIONS = Object.freeze([
162
+ "blend",
163
+ "dae",
164
+ "fbx",
165
+ "obj"
166
+ ]);
167
+ var RESULT_PREFIX = "TN_BLENDER_RESULT ";
168
+ var DEFAULT_TIMEOUT_MS = 3e5;
169
+ function blenderScriptsDirectory(environment = process.env) {
170
+ const override = environment.THREENATIVE_BLENDER_SCRIPTS;
171
+ if (override !== void 0 && override.trim().length > 0) return path2.resolve(override.trim());
172
+ return path2.resolve(fileURLToPath(import.meta.url), "..", "..", "gpl");
173
+ }
174
+ function failure(cause, detail, stderr) {
175
+ return {
176
+ cause,
177
+ detail,
178
+ install: BLENDER_INSTALL_GUIDANCE,
179
+ installCommand: installCommandFor(),
180
+ ok: false,
181
+ ...stderr === void 0 ? {} : { stderr }
182
+ };
183
+ }
184
+ function temporaryRoot(environment) {
185
+ const named = environment.TMPDIR ?? environment.TEMP ?? environment.TMP;
186
+ if (named !== void 0 && named.trim().length > 0 && existsSync(named.trim())) {
187
+ return path2.resolve(named.trim());
188
+ }
189
+ return os.tmpdir();
190
+ }
191
+ function unavailable(status) {
192
+ const cause = status.cause === "blender-too-old" ? "blender-too-old" : status.cause === "blender-unreadable" ? "blender-unreadable" : "blender-missing";
193
+ return failure(cause, status.detail);
194
+ }
195
+ function parseBlenderResult(stdout) {
196
+ const line = stdout.split("\n").reverse().find((candidate) => candidate.startsWith(RESULT_PREFIX));
197
+ if (line === void 0) return void 0;
198
+ try {
199
+ return JSON.parse(line.slice(RESULT_PREFIX.length));
200
+ } catch {
201
+ return void 0;
202
+ }
203
+ }
204
+ function runBlender(binary, script, request, timeoutMs, environment) {
205
+ return new Promise((resolve) => {
206
+ const args = [
207
+ "--background",
208
+ "--factory-startup",
209
+ "--python-exit-code",
210
+ "3",
211
+ "--python",
212
+ script,
213
+ "--",
214
+ JSON.stringify(request)
215
+ ];
216
+ execFile(
217
+ binary,
218
+ args,
219
+ { encoding: "utf8", env: environment, maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs },
220
+ (error, stdout, stderr) => {
221
+ const killed = error?.killed === true;
222
+ const code = error?.code;
223
+ resolve({
224
+ code: error === null ? 0 : typeof code === "number" ? code : null,
225
+ stderr,
226
+ stdout,
227
+ timedOut: killed
228
+ });
229
+ }
230
+ );
231
+ });
232
+ }
233
+ function assertSourcePath(source) {
234
+ if (typeof source !== "string" || source.trim().length === 0) {
235
+ throw new Error("TN_BLENDER_BRIDGE: 'source' must be a non-empty path.");
236
+ }
237
+ return path2.resolve(source);
238
+ }
239
+ async function run(request, options) {
240
+ const environment = options.environment ?? process.env;
241
+ const status = resolveBlender(environment);
242
+ if (!status.available || status.path === void 0) return unavailable(status);
243
+ const scripts = options.scriptsDirectory ?? blenderScriptsDirectory(environment);
244
+ const script = options.script ?? path2.join(scripts, "convert.py");
245
+ if (!existsSync(script)) {
246
+ return failure(
247
+ "script-missing",
248
+ `The Blender script '${script}' is not on disk. Set THREENATIVE_BLENDER_SCRIPTS to the package's gpl/ directory.`
249
+ );
250
+ }
251
+ const temporary = await mkdtemp(path2.join(temporaryRoot(environment), "tn-blender-"));
252
+ let outcome;
253
+ try {
254
+ outcome = await runBlender(
255
+ status.path,
256
+ script,
257
+ request,
258
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
259
+ { ...environment, TEMP: temporary, TMP: temporary, TMPDIR: temporary }
260
+ );
261
+ } finally {
262
+ await rm(temporary, { force: true, recursive: true });
263
+ }
264
+ if (outcome.timedOut) {
265
+ return failure(
266
+ "timeout",
267
+ `Blender did not finish within ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms.`,
268
+ outcome.stderr
269
+ );
270
+ }
271
+ const summary = parseBlenderResult(outcome.stdout);
272
+ if (outcome.code !== 0) {
273
+ const named = outcome.stderr.split("\n").find((line) => line.startsWith("TN_BLENDER_ERROR:"));
274
+ const noMeshes = named?.includes("produced no meshes") === true;
275
+ return failure(
276
+ noMeshes ? "no-meshes" : "convert-failed",
277
+ named ?? `Blender exited ${outcome.code ?? "by signal"} for '${String(request.source)}'.`,
278
+ outcome.stderr
279
+ );
280
+ }
281
+ if (summary === void 0) {
282
+ return failure(
283
+ "unreadable-result",
284
+ "Blender exited 0 but printed no readable result line; refusing to report a conversion that cannot be described.",
285
+ outcome.stderr
286
+ );
287
+ }
288
+ if (summary.meshes === 0) {
289
+ return failure("no-meshes", `'${summary.source}' produced no meshes.`, outcome.stderr);
290
+ }
291
+ if (request.mode === "convert") {
292
+ const out = summary.out;
293
+ if (out === void 0 || !existsSync(out)) {
294
+ return failure(
295
+ "no-output",
296
+ `Blender reported success but wrote no file at '${String(request.out)}'.`,
297
+ outcome.stderr
298
+ );
299
+ }
300
+ }
301
+ return { ok: true, summary };
302
+ }
303
+ async function inspectModel(source, options = {}) {
304
+ return run({ mode: "inspect", source: assertSourcePath(source) }, options);
305
+ }
306
+ async function convertModel(source, out, options = {}) {
307
+ if (typeof out !== "string" || out.trim().length === 0) {
308
+ throw new Error("TN_BLENDER_BRIDGE: 'out' must be a non-empty path.");
309
+ }
310
+ return run(
311
+ { mode: "convert", out: path2.resolve(out), source: assertSourcePath(source) },
312
+ options
313
+ );
314
+ }
315
+ async function runBlenderScript(script, request, options = {}) {
316
+ if (typeof script !== "string" || script.trim().length === 0) {
317
+ throw new Error("TN_BLENDER_BRIDGE: 'script' must be a non-empty path.");
318
+ }
319
+ return run(request, { ...options, script: path2.resolve(script) });
320
+ }
321
+ var PATH_PARAMETERS = [
322
+ { description: "Path to the model to read.", name: "source", required: true },
323
+ { description: "Path of the file to write.", name: "out", required: true }
324
+ ];
325
+ var RECIPES = Object.freeze([
326
+ Object.freeze({
327
+ description: "Reduce triangle count to a requested fraction, preserving materials, UVs and rig. Reports trianglesBefore, trianglesAfter and achievedRatio.",
328
+ name: "decimate",
329
+ parameters: Object.freeze([
330
+ ...PATH_PARAMETERS,
331
+ {
332
+ description: "Target fraction of the original triangles, 0 < ratio <= 1. Default 0.5.",
333
+ name: "ratio",
334
+ required: false
335
+ }
336
+ ]),
337
+ script: "decimate.py"
338
+ }),
339
+ Object.freeze({
340
+ description: "Give meshes a UV layer they do not have, so they can carry a texture or a lightmap. Reports uvLayersBefore and uvLayersAfter.",
341
+ name: "unwrap",
342
+ parameters: Object.freeze([
343
+ ...PATH_PARAMETERS,
344
+ {
345
+ description: "Smart-project angle limit in degrees. Default 66.",
346
+ name: "angleLimit",
347
+ required: false
348
+ },
349
+ { description: "Island margin. Default 0.02.", name: "islandMargin", required: false },
350
+ {
351
+ description: "Only unwrap meshes with no UV layer at all. Default true.",
352
+ name: "onlyMissing",
353
+ required: false
354
+ }
355
+ ]),
356
+ script: "unwrap.py"
357
+ }),
358
+ Object.freeze({
359
+ description: "Bake ambient occlusion \u2014 computed from geometry \u2014 into a PNG. Reports the baked image's mean, min and max luminance.",
360
+ name: "bake_ao",
361
+ parameters: Object.freeze([
362
+ ...PATH_PARAMETERS,
363
+ { description: "Square texture size in pixels. Default 256.", name: "size", required: false },
364
+ { description: "Cycles samples. Default 16.", name: "samples", required: false }
365
+ ]),
366
+ script: "bake_ao.py"
367
+ }),
368
+ Object.freeze({
369
+ description: "Move animation clips from one armature's bone names onto another's. Fails when a clip resolves no track on the destination armature.",
370
+ name: "retarget",
371
+ parameters: Object.freeze([
372
+ { description: "Model carrying the clips to move.", name: "source", required: true },
373
+ { description: "Model whose armature receives them.", name: "target", required: true },
374
+ { description: "Path of the .glb to write.", name: "out", required: true },
375
+ {
376
+ description: "Object mapping source bone name to destination bone name.",
377
+ name: "map",
378
+ required: true
379
+ }
380
+ ]),
381
+ script: "retarget.py"
382
+ })
383
+ ]);
384
+ function recipeNames() {
385
+ return RECIPES.map((recipe) => recipe.name);
386
+ }
387
+ function findRecipe(name) {
388
+ if (typeof name !== "string" || name.trim().length === 0) {
389
+ throw new Error("blender_recipes requires a string 'name' argument, or none to list them all.");
390
+ }
391
+ const recipe = RECIPES.find((candidate) => candidate.name === name);
392
+ if (recipe === void 0) {
393
+ throw new Error(`Unknown recipe '${name}'. Shipped recipes: ${recipeNames().join(", ")}.`);
394
+ }
395
+ return recipe;
396
+ }
397
+ function recipePath(recipe, environment = process.env) {
398
+ return path2.join(blenderScriptsDirectory(environment), "recipes", recipe.script);
399
+ }
400
+ function recipeSource(recipe, environment = process.env) {
401
+ const file = recipePath(recipe, environment);
402
+ if (!existsSync(file)) {
403
+ throw new Error(`TN_BLENDER_RECIPE_MISSING: recipe '${recipe.name}' has no script at ${file}.`);
404
+ }
405
+ return readFileSync(file, "utf8");
406
+ }
407
+ async function runRecipe(name, request, options = {}) {
408
+ const recipe = findRecipe(name);
409
+ const environment = options.environment ?? process.env;
410
+ const script = recipePath(recipe, environment);
411
+ if (!existsSync(script)) {
412
+ throw new Error(
413
+ `TN_BLENDER_RECIPE_MISSING: recipe '${recipe.name}' has no script at ${script}.`
414
+ );
415
+ }
416
+ for (const parameter of recipe.parameters) {
417
+ if (!parameter.required) continue;
418
+ if (request[parameter.name] === void 0) {
419
+ throw new Error(`Recipe '${recipe.name}' requires the '${parameter.name}' argument.`);
420
+ }
421
+ }
422
+ return runBlenderScript(script, request, options);
423
+ }
424
+
425
+ // src/index.ts
426
+ var SERVER_NAME = "threenative-blender-mcp";
427
+ var SERVER_VERSION = "0.1.1";
428
+ var AUTHORING_INSTRUCTIONS = "Call blender_status before anything else: it answers whether this machine can convert models at all, and when it cannot it names the install command rather than failing. Blender is never installed for the user \u2014 ask first, then let them run the command. An .fbx, .blend, .obj or .dae placed in a game's assets directory is converted by the build itself; these tools are for inspecting a source before committing it and for operations the build does not perform. For anything no named tool covers, read a shipped recipe with blender_recipes and adapt it, then run it with blender_run_python.";
429
+ var TOOL_DEFINITIONS = [
430
+ {
431
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
432
+ name: "blender_status",
433
+ description: "Report whether this machine has a supported Blender, where it is and what version. Never fails: when Blender is absent it returns available:false with the install command for each platform.",
434
+ inputSchema: { additionalProperties: false, properties: {}, type: "object" }
435
+ },
436
+ {
437
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
438
+ name: "blender_inspect",
439
+ description: `Report what a model file contains without writing anything: mesh, triangle, vertex and bone counts, material names, animation clip names and image names. Accepts ${BLENDER_SOURCE_EXTENSIONS.join(", ")}, .gltf and .glb.`,
440
+ inputSchema: {
441
+ additionalProperties: false,
442
+ properties: {
443
+ source: { description: "Path to the model file to inspect.", type: "string" }
444
+ },
445
+ required: ["source"],
446
+ type: "object"
447
+ }
448
+ },
449
+ {
450
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: false },
451
+ name: "blender_convert",
452
+ description: "Convert a model file to a GLB a ThreeNative game can load, preserving skeletons, animation clips, materials and textures. A conversion that produces no meshes fails rather than writing an empty GLB. Note that a game's build converts these files itself: use this to check a source before committing it, or to write a GLB somewhere else.",
453
+ inputSchema: {
454
+ additionalProperties: false,
455
+ properties: {
456
+ out: { description: "Path of the .glb to write.", type: "string" },
457
+ source: { description: "Path to the model file to convert.", type: "string" }
458
+ },
459
+ required: ["source", "out"],
460
+ type: "object"
461
+ }
462
+ },
463
+ {
464
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: false },
465
+ name: "blender_recipes",
466
+ description: "List the shipped bpy recipes, read one's full source text, or run one. Read before you write: a working recipe adapted into your game beats bpy written cold. Omit 'name' to list; pass 'name' alone to read; pass 'name' with 'arguments' to run.",
467
+ inputSchema: {
468
+ additionalProperties: false,
469
+ properties: {
470
+ arguments: {
471
+ description: "Recipe arguments. Present means run it; absent means return its source.",
472
+ type: "object"
473
+ },
474
+ name: { description: "Recipe name. Omit to list every recipe.", type: "string" }
475
+ },
476
+ type: "object"
477
+ }
478
+ },
479
+ {
480
+ annotations: { destructiveHint: true, openWorldHint: false, readOnlyHint: false },
481
+ name: "blender_run_python",
482
+ description: "Run a bpy script of your own inside Blender, with a timeout, returning its result line, stderr and exit code. This is NOT a sandbox and does not claim to be: the script runs with your own privileges, exactly as it would if you invoked Blender through Bash yourself. What it adds is a resolved Blender, the same fail-closed handling every other tool here uses, and discoverability \u2014 not privilege. Read blender_recipes first; the shipped recipes are the worked examples.",
483
+ inputSchema: {
484
+ additionalProperties: false,
485
+ properties: {
486
+ arguments: {
487
+ description: "The JSON request handed to the script after '--'.",
488
+ type: "object"
489
+ },
490
+ script: { description: "Path to the .py file to run inside Blender.", type: "string" },
491
+ timeoutMs: { description: "Kill the run after this many milliseconds.", type: "number" }
492
+ },
493
+ required: ["script"],
494
+ type: "object"
495
+ }
496
+ }
497
+ ];
498
+ function isRecord(value) {
499
+ return typeof value === "object" && value !== null && !Array.isArray(value);
500
+ }
501
+ function jsonRpcError(id, code, message) {
502
+ return JSON.stringify({ id, jsonrpc: "2.0", error: { code, message } });
503
+ }
504
+ function jsonRpcResult(id, result) {
505
+ return JSON.stringify({ id, jsonrpc: "2.0", result });
506
+ }
507
+ function writeLine(value) {
508
+ process.stdout.write(`${value}
509
+ `);
510
+ }
511
+ function toolText(value) {
512
+ return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] };
513
+ }
514
+ function blenderStatusResult(status = resolveBlender()) {
515
+ return {
516
+ available: status.available,
517
+ ...status.cause === void 0 ? {} : { cause: status.cause },
518
+ detail: status.detail,
519
+ install: status.install,
520
+ installCommand: installCommandFor(),
521
+ minimumVersion: status.minimumVersion,
522
+ ...status.path === void 0 ? {} : { path: status.path },
523
+ ...status.version === void 0 ? {} : { version: status.version }
524
+ };
525
+ }
526
+ function requiredString(argumentsValue, key, tool) {
527
+ const value = argumentsValue[key];
528
+ if (typeof value !== "string" || value.trim().length === 0) {
529
+ throw new Error(`${tool} requires a non-empty string '${key}' argument.`);
530
+ }
531
+ return value;
532
+ }
533
+ async function handleRecipes(argumentsValue) {
534
+ if (argumentsValue.name === void 0) {
535
+ return {
536
+ recipes: RECIPES.map((recipe2) => ({
537
+ description: recipe2.description,
538
+ name: recipe2.name,
539
+ parameters: recipe2.parameters,
540
+ script: recipePath(recipe2)
541
+ }))
542
+ };
543
+ }
544
+ const recipe = findRecipe(argumentsValue.name);
545
+ if (argumentsValue.arguments === void 0) {
546
+ return {
547
+ description: recipe.description,
548
+ name: recipe.name,
549
+ parameters: recipe.parameters,
550
+ script: recipePath(recipe),
551
+ source: recipeSource(recipe)
552
+ };
553
+ }
554
+ if (!isRecord(argumentsValue.arguments)) {
555
+ throw new Error("blender_recipes 'arguments' must be an object.");
556
+ }
557
+ return runRecipe(recipe.name, argumentsValue.arguments);
558
+ }
559
+ async function handleToolCall(params) {
560
+ const name = params.name;
561
+ const argumentsValue = isRecord(params.arguments) ? params.arguments : {};
562
+ if (name === "blender_status") return toolText(blenderStatusResult());
563
+ if (name === "blender_inspect") {
564
+ const source = requiredString(argumentsValue, "source", "blender_inspect");
565
+ return toolText(await inspectModel(source));
566
+ }
567
+ if (name === "blender_convert") {
568
+ const source = requiredString(argumentsValue, "source", "blender_convert");
569
+ const out = requiredString(argumentsValue, "out", "blender_convert");
570
+ return toolText(await convertModel(source, out));
571
+ }
572
+ if (name === "blender_recipes") return toolText(await handleRecipes(argumentsValue));
573
+ if (name === "blender_run_python") {
574
+ const script = requiredString(argumentsValue, "script", "blender_run_python");
575
+ const request = isRecord(argumentsValue.arguments) ? argumentsValue.arguments : {};
576
+ const timeoutMs = argumentsValue.timeoutMs;
577
+ if (timeoutMs !== void 0 && typeof timeoutMs !== "number") {
578
+ throw new Error("blender_run_python 'timeoutMs' must be a number.");
579
+ }
580
+ return toolText(
581
+ await runBlenderScript(script, request, timeoutMs === void 0 ? {} : { timeoutMs })
582
+ );
583
+ }
584
+ throw new Error(`Unknown blender MCP tool '${String(name)}'.`);
585
+ }
586
+ async function handleLine(line) {
587
+ if (line.trim().length === 0) return void 0;
588
+ let request;
589
+ try {
590
+ request = JSON.parse(line);
591
+ } catch (error) {
592
+ return jsonRpcError(null, -32700, `Invalid JSON: ${String(error)}`);
593
+ }
594
+ if (!isRecord(request) || request.id === void 0 || typeof request.method !== "string")
595
+ return void 0;
596
+ try {
597
+ if (request.method === "initialize") {
598
+ return jsonRpcResult(request.id, {
599
+ capabilities: { tools: { listChanged: false } },
600
+ instructions: AUTHORING_INSTRUCTIONS,
601
+ protocolVersion: "2025-06-18",
602
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
603
+ });
604
+ }
605
+ if (request.method === "tools/list") {
606
+ return jsonRpcResult(request.id, { tools: TOOL_DEFINITIONS });
607
+ }
608
+ if (request.method === "tools/call") {
609
+ if (!isRecord(request.params)) throw new Error("tools/call requires an object params value.");
610
+ return jsonRpcResult(request.id, await handleToolCall(request.params));
611
+ }
612
+ return jsonRpcError(request.id, -32601, `Method not found: ${request.method}`);
613
+ } catch (error) {
614
+ return jsonRpcError(request.id, -32e3, error instanceof Error ? error.message : String(error));
615
+ }
616
+ }
617
+ function runServer() {
618
+ const input = createInterface({ input: process.stdin });
619
+ let queue = Promise.resolve();
620
+ input.on("line", (line) => {
621
+ queue = queue.then(async () => {
622
+ const response = await handleLine(line);
623
+ if (response !== void 0) writeLine(response);
624
+ });
625
+ });
626
+ }
627
+ var entryPath = process.argv[1];
628
+ if (entryPath !== void 0 && realpathSync(path2.resolve(entryPath)) === realpathSync(fileURLToPath(import.meta.url))) {
629
+ runServer();
630
+ }
631
+
632
+ export { TOOL_DEFINITIONS, blenderStatusResult, handleLine, handleToolCall, runServer };
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { launchMcpServer } from "./launch.mjs";
6
+ import { MCP_PACKAGES } from "./servers.mjs";
7
+
8
+ // Core carries the built server beside this shim, so a project that installed `@threenative/core`
9
+ // has working Blender tools with no network and no extra install.
10
+ //
11
+ // `blender-server.mjs` is generated by `scripts/bundle-blender-mcp.mjs` **and committed**, unlike
12
+ // its `engine-server.mjs` neighbour, which is gitignored. The difference matters: the npx fallback
13
+ // below cannot work for this server, because `threenative-blender-mcp` has never been published —
14
+ // a missing bundle becomes
15
+ //
16
+ // threenative-blender exited before completing MCP requests (1): npm error code E404
17
+ //
18
+ // which is what the golden-path matrix reported when the file was gitignored and CI's packed core
19
+ // tarball therefore did not contain it. `mcp-install.spec.ts` holds the committed copy equal to the
20
+ // current build output, the way `capabilities.json` and the census records are held.
21
+ const localServer = path.resolve(fileURLToPath(import.meta.url), "..", "blender-server.mjs");
22
+ if (existsSync(localServer)) {
23
+ const { runServer } = await import("./blender-server.mjs");
24
+ runServer();
25
+ } else {
26
+ await launchMcpServer(MCP_PACKAGES.blender);
27
+ }