bambu-printer-mcp 1.1.6 → 1.1.9
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/CONTRIBUTORS.md +27 -0
- package/README.md +103 -28
- package/dist/blender-mcp-bridge.d.ts +10 -0
- package/dist/blender-mcp-bridge.js +389 -0
- package/dist/index.js +155 -91
- package/dist/printers/bambu.d.ts +3 -2
- package/dist/printers/bambu.js +49 -18
- package/dist/slicer/profile-flatten.d.ts +29 -4
- package/dist/slicer/profile-flatten.js +269 -74
- package/dist/stl/stl-manipulator.d.ts +4 -17
- package/dist/stl/stl-manipulator.js +161 -85
- package/package.json +8 -4
- package/patches/bambu-node+3.22.21.patch +18 -2
- package/scripts/install-patches.mjs +64 -0
- package/src/blender-mcp-bridge.ts +344 -0
- package/src/index.ts +169 -114
- package/src/printers/bambu.ts +49 -18
- package/src/slicer/profile-flatten.ts +307 -82
- package/src/stl/stl-manipulator.ts +184 -98
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
4
|
+
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const MAX_STL_BYTES = 256 * 1024 * 1024;
|
|
13
|
+
const RECEIPT_PREFIX = "BAMBU_STL_RESULT:";
|
|
14
|
+
class BlenderError extends Error {
|
|
15
|
+
}
|
|
16
|
+
function argumentObject(value, label) {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
18
|
+
throw new BlenderError(`${label} must be an object.`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function allowedArguments(args, names) {
|
|
22
|
+
if (Object.keys(args).some((name) => !names.includes(name))) {
|
|
23
|
+
throw new BlenderError("Unknown Blender argument. Executables and their arguments must be configured on the server with BLENDER_MCP_COMMAND and BLENDER_MCP_ARGS.");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function textArgument(value, label) {
|
|
27
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0"))
|
|
28
|
+
throw new BlenderError(`${label} must be a non-empty string without NUL characters.`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function booleanArgument(value, label, fallback = false) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return fallback;
|
|
34
|
+
if (typeof value !== "boolean")
|
|
35
|
+
throw new BlenderError(`${label} must be a boolean.`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function timeoutMilliseconds(value) {
|
|
39
|
+
const timeout = value ?? (process.env.BLENDER_MCP_TIMEOUT_MS ? Number(process.env.BLENDER_MCP_TIMEOUT_MS) : 120000);
|
|
40
|
+
if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 100 || timeout > 300000) {
|
|
41
|
+
throw new BlenderError("timeout_ms / BLENDER_MCP_TIMEOUT_MS must be an integer from 100 to 300000.");
|
|
42
|
+
}
|
|
43
|
+
return timeout;
|
|
44
|
+
}
|
|
45
|
+
function standardConfiguration() {
|
|
46
|
+
const command = process.env.BLENDER_MCP_COMMAND?.trim();
|
|
47
|
+
if (!command)
|
|
48
|
+
throw new BlenderError("No Blender MCP server configured. Set BLENDER_MCP_COMMAND to an executable and BLENDER_MCP_ARGS to a JSON string array (for example uvx and [\"blender-mcp\"]). Start the matching addon in Blender.");
|
|
49
|
+
let args;
|
|
50
|
+
try {
|
|
51
|
+
args = JSON.parse(process.env.BLENDER_MCP_ARGS?.trim() || "[]");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new BlenderError("BLENDER_MCP_ARGS must be a JSON array of strings, not a shell command.");
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
|
|
57
|
+
throw new BlenderError("BLENDER_MCP_ARGS must be a JSON array of strings without NUL characters.");
|
|
58
|
+
}
|
|
59
|
+
if (command.includes("\0"))
|
|
60
|
+
throw new BlenderError("BLENDER_MCP_COMMAND must be an executable without NUL characters.");
|
|
61
|
+
return { command, args };
|
|
62
|
+
}
|
|
63
|
+
function blenderEnvironment() {
|
|
64
|
+
// Blender needs its connection/settings, not printer access codes or other server secrets.
|
|
65
|
+
const env = getDefaultEnvironment();
|
|
66
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
67
|
+
if (key.startsWith("BLENDER_") && value !== undefined && !["BLENDER_MCP_COMMAND", "BLENDER_MCP_ARGS", "BLENDER_MCP_BRIDGE_COMMAND"].includes(key))
|
|
68
|
+
env[key] = value;
|
|
69
|
+
}
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
function normalizeToolFailure(result) {
|
|
73
|
+
if (result.isError)
|
|
74
|
+
return result;
|
|
75
|
+
const failureObject = (value) => Boolean(value && typeof value === "object" &&
|
|
76
|
+
(value.error || value.status === "error" || value.success === false));
|
|
77
|
+
// Common Blender MCP releases catch addon errors and return ordinary text with isError unset.
|
|
78
|
+
const failed = failureObject(result.structuredContent) || result.content.some((entry) => {
|
|
79
|
+
if (entry.type !== "text")
|
|
80
|
+
return false;
|
|
81
|
+
if (/^\s*(?:Error(?:\s[^:\n]*)?:|Rejected by safe mode\b)/i.test(entry.text))
|
|
82
|
+
return true;
|
|
83
|
+
try {
|
|
84
|
+
return failureObject(JSON.parse(entry.text));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return failed ? { ...result, isError: true } : result;
|
|
91
|
+
}
|
|
92
|
+
async function regularStl(filePath) {
|
|
93
|
+
const stat = await fs.lstat(filePath).catch(() => { throw new BlenderError("STL file does not exist or is not readable."); });
|
|
94
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_STL_BYTES)
|
|
95
|
+
throw new BlenderError("STL must be a non-empty regular file of at most 256 MiB.");
|
|
96
|
+
const buffer = await fs.readFile(filePath);
|
|
97
|
+
let geometry;
|
|
98
|
+
try {
|
|
99
|
+
geometry = new STLLoader().parse(Uint8Array.from(buffer).buffer);
|
|
100
|
+
const positions = geometry.getAttribute("position");
|
|
101
|
+
if (!positions || positions.count < 3 || positions.count % 3 !== 0)
|
|
102
|
+
throw new Error("invalid vertices");
|
|
103
|
+
// Scan the typed array in place; boxing a large mesh can exhaust the JS heap.
|
|
104
|
+
for (let index = 0; index < positions.array.length; index++) {
|
|
105
|
+
if (!Number.isFinite(positions.array[index]))
|
|
106
|
+
throw new Error("invalid vertices");
|
|
107
|
+
}
|
|
108
|
+
return { bytes: stat.size, triangles: positions.count / 3 };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new BlenderError("STL contains no valid finite triangle mesh.");
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
geometry?.dispose();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function inputPath(value, label) {
|
|
118
|
+
const filePath = path.resolve(textArgument(value, label));
|
|
119
|
+
if (path.extname(filePath).toLowerCase() !== ".stl")
|
|
120
|
+
throw new BlenderError(`${label} must name an STL file.`);
|
|
121
|
+
const resolved = await fs.realpath(filePath).catch(() => { throw new BlenderError(`${label} does not exist.`); });
|
|
122
|
+
await regularStl(resolved);
|
|
123
|
+
return resolved;
|
|
124
|
+
}
|
|
125
|
+
async function editPlan(args) {
|
|
126
|
+
const stlPath = await inputPath(args.stl_path, "stl_path");
|
|
127
|
+
const requestedOutput = path.resolve(textArgument(args.output_path, "output_path"));
|
|
128
|
+
if (path.extname(requestedOutput).toLowerCase() !== ".stl")
|
|
129
|
+
throw new BlenderError("output_path must name a new STL file.");
|
|
130
|
+
const directory = await fs.realpath(path.dirname(requestedOutput)).catch(() => { throw new BlenderError("output_path parent directory must already exist."); });
|
|
131
|
+
const outputPath = path.join(directory, path.basename(requestedOutput));
|
|
132
|
+
const existing = await fs.lstat(outputPath).catch((error) => {
|
|
133
|
+
if (error.code !== "ENOENT")
|
|
134
|
+
throw new BlenderError("output_path cannot be accessed.");
|
|
135
|
+
return undefined;
|
|
136
|
+
});
|
|
137
|
+
if (existing)
|
|
138
|
+
throw new BlenderError("output_path already exists; choose a new file. Input files are never overwritten.");
|
|
139
|
+
const operations = [];
|
|
140
|
+
for (const operation of args.operations) {
|
|
141
|
+
const separator = operation.indexOf(":");
|
|
142
|
+
const type = operation.slice(0, separator);
|
|
143
|
+
const value = operation.slice(separator + 1);
|
|
144
|
+
if (separator > 0 && type === "boolean_union") {
|
|
145
|
+
operations.push({ type, path: await inputPath(value, "boolean_union operand") });
|
|
146
|
+
}
|
|
147
|
+
else if (separator > 0 && type === "decimate" && value.trim() && Number.isFinite(Number(value)) && Number(value) > 0 && Number(value) <= 1) {
|
|
148
|
+
operations.push({ type, ratio: Number(value) });
|
|
149
|
+
}
|
|
150
|
+
else if (separator > 0 && type === "remesh" && value.trim() && Number.isFinite(Number(value)) && Number(value) > 0) {
|
|
151
|
+
operations.push({ type, voxelSize: Number(value) });
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw new BlenderError("Unsupported edit operation. Use decimate:<ratio 0..1>, remesh:<positive voxel size in STL units>, or boolean_union:<STL path>. Use blender_mcp_call for other Blender capabilities.");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const requestId = randomUUID();
|
|
158
|
+
return { requestId, stlPath, outputPath, stagedOutputPath: path.join(directory, `.bambu-${requestId}.stl`), operations };
|
|
159
|
+
}
|
|
160
|
+
function editScript(plan) {
|
|
161
|
+
// JSON is data, never interpolated Python syntax. The comment is also useful in dry runs.
|
|
162
|
+
return `# BAMBU_EDIT_PLAN ${JSON.stringify(plan)}
|
|
163
|
+
import bpy
|
|
164
|
+
import json
|
|
165
|
+
|
|
166
|
+
def _bambu_edit():
|
|
167
|
+
plan = json.loads(${JSON.stringify(JSON.stringify(plan))})
|
|
168
|
+
if bpy.context.mode != 'OBJECT':
|
|
169
|
+
raise RuntimeError('Switch Blender to Object Mode before editing an STL')
|
|
170
|
+
objects_before = set(bpy.data.objects)
|
|
171
|
+
meshes_before = set(bpy.data.meshes)
|
|
172
|
+
selected_before = list(bpy.context.selected_objects)
|
|
173
|
+
active_before = bpy.context.view_layer.objects.active
|
|
174
|
+
|
|
175
|
+
def select_only(objects):
|
|
176
|
+
for obj in list(bpy.context.selected_objects):
|
|
177
|
+
obj.select_set(False)
|
|
178
|
+
for obj in objects:
|
|
179
|
+
obj.select_set(True)
|
|
180
|
+
bpy.context.view_layer.objects.active = objects[0]
|
|
181
|
+
|
|
182
|
+
def import_stl(filepath):
|
|
183
|
+
before = set(bpy.data.objects)
|
|
184
|
+
if bpy.app.version >= (4, 0, 0):
|
|
185
|
+
bpy.ops.wm.stl_import(filepath=filepath, global_scale=1.0, use_scene_unit=False, forward_axis='Y', up_axis='Z')
|
|
186
|
+
else:
|
|
187
|
+
bpy.ops.import_mesh.stl(filepath=filepath, global_scale=1.0, use_scene_unit=False, axis_forward='Y', axis_up='Z')
|
|
188
|
+
imported = [obj for obj in bpy.data.objects if obj not in before and obj.type == 'MESH']
|
|
189
|
+
if not imported:
|
|
190
|
+
raise RuntimeError('STL import produced no mesh objects')
|
|
191
|
+
select_only(imported)
|
|
192
|
+
if len(imported) > 1:
|
|
193
|
+
bpy.ops.object.join()
|
|
194
|
+
return bpy.context.view_layer.objects.active
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
target = import_stl(plan['stlPath'])
|
|
198
|
+
for operation in plan['operations']:
|
|
199
|
+
operand = None
|
|
200
|
+
if operation['type'] == 'boolean_union':
|
|
201
|
+
operand = import_stl(operation['path'])
|
|
202
|
+
select_only([target])
|
|
203
|
+
if operation['type'] == 'decimate':
|
|
204
|
+
modifier = target.modifiers.new('Bambu decimate', 'DECIMATE')
|
|
205
|
+
modifier.ratio = operation['ratio']
|
|
206
|
+
elif operation['type'] == 'remesh':
|
|
207
|
+
modifier = target.modifiers.new('Bambu remesh', 'REMESH')
|
|
208
|
+
modifier.mode = 'VOXEL'
|
|
209
|
+
modifier.voxel_size = operation['voxelSize']
|
|
210
|
+
else:
|
|
211
|
+
modifier = target.modifiers.new('Bambu union', 'BOOLEAN')
|
|
212
|
+
modifier.operation = 'UNION'
|
|
213
|
+
modifier.solver = 'EXACT'
|
|
214
|
+
modifier.object = operand
|
|
215
|
+
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
|
216
|
+
if operand is not None:
|
|
217
|
+
bpy.data.objects.remove(operand, do_unlink=True)
|
|
218
|
+
if len(target.data.polygons) == 0:
|
|
219
|
+
raise RuntimeError('Edits produced an empty mesh')
|
|
220
|
+
select_only([target])
|
|
221
|
+
if bpy.app.version >= (4, 0, 0):
|
|
222
|
+
result = bpy.ops.wm.stl_export(filepath=plan['stagedOutputPath'], check_existing=False, export_selected_objects=True, global_scale=1.0, use_scene_unit=False, forward_axis='Y', up_axis='Z', apply_modifiers=True)
|
|
223
|
+
else:
|
|
224
|
+
result = bpy.ops.export_mesh.stl(filepath=plan['stagedOutputPath'], check_existing=False, use_selection=True, global_scale=1.0, use_scene_unit=False, axis_forward='Y', axis_up='Z', use_mesh_modifiers=True)
|
|
225
|
+
if 'FINISHED' not in result:
|
|
226
|
+
raise RuntimeError('STL export did not finish')
|
|
227
|
+
finally:
|
|
228
|
+
for obj in list(bpy.data.objects):
|
|
229
|
+
if obj not in objects_before:
|
|
230
|
+
bpy.data.objects.remove(obj, do_unlink=True)
|
|
231
|
+
for mesh in list(bpy.data.meshes):
|
|
232
|
+
if mesh not in meshes_before and mesh.users == 0:
|
|
233
|
+
bpy.data.meshes.remove(mesh)
|
|
234
|
+
for obj in selected_before:
|
|
235
|
+
obj.select_set(True)
|
|
236
|
+
bpy.context.view_layer.objects.active = active_before
|
|
237
|
+
print('${RECEIPT_PREFIX}' + json.dumps({'requestId': plan['requestId'], 'outputPath': plan['stagedOutputPath']}))
|
|
238
|
+
|
|
239
|
+
_bambu_edit()
|
|
240
|
+
del _bambu_edit
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
export class BlenderMcpBridge {
|
|
244
|
+
async session(timeout, signal, work) {
|
|
245
|
+
const config = standardConfiguration();
|
|
246
|
+
const client = new Client({ name: "bambu-printer-mcp-blender", version: "1.0.0" });
|
|
247
|
+
const transport = new StdioClientTransport({ ...config, env: blenderEnvironment(), stderr: "pipe" });
|
|
248
|
+
// Drain stderr without relaying arbitrary logs or credentials into the outer MCP stream.
|
|
249
|
+
transport.stderr?.on("data", () => { });
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
let timedOut = false;
|
|
252
|
+
let phase = "initialization";
|
|
253
|
+
let dispatched = false;
|
|
254
|
+
const deadline = Date.now() + timeout;
|
|
255
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);
|
|
256
|
+
const abort = () => controller.abort();
|
|
257
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
258
|
+
if (signal?.aborted)
|
|
259
|
+
controller.abort();
|
|
260
|
+
const options = () => ({ signal: controller.signal, timeout: Math.max(1, deadline - Date.now()) });
|
|
261
|
+
try {
|
|
262
|
+
await client.connect(transport, options());
|
|
263
|
+
phase = "tool discovery";
|
|
264
|
+
const tools = [];
|
|
265
|
+
const cursors = new Set();
|
|
266
|
+
let cursor;
|
|
267
|
+
do {
|
|
268
|
+
const page = await client.listTools(cursor ? { cursor } : {}, options());
|
|
269
|
+
tools.push(...page.tools);
|
|
270
|
+
cursor = page.nextCursor;
|
|
271
|
+
if (cursor && (cursors.has(cursor) || cursors.size >= 99))
|
|
272
|
+
throw new BlenderError("Blender MCP returned an invalid or excessive tools/list pagination sequence.");
|
|
273
|
+
if (cursor)
|
|
274
|
+
cursors.add(cursor);
|
|
275
|
+
} while (cursor);
|
|
276
|
+
phase = "tool request";
|
|
277
|
+
// A request may already have reached Blender when an interruption occurs. Never retry it here.
|
|
278
|
+
return await work({ client, tools, options, markDispatched: () => { dispatched = true; } });
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error instanceof BlenderError)
|
|
282
|
+
throw error;
|
|
283
|
+
const code = error?.code;
|
|
284
|
+
if (timedOut || code === ErrorCode.RequestTimeout) {
|
|
285
|
+
throw new BlenderError(`Blender MCP timed out during ${phase}.${dispatched ? " Execution status is unknown; inspect Blender before retrying. The request was not replayed." : " Check the configured server command and Blender addon connection."}`);
|
|
286
|
+
}
|
|
287
|
+
if (controller.signal.aborted)
|
|
288
|
+
throw new BlenderError(`Blender MCP request cancelled.${dispatched ? " Execution may still be running in Blender; inspect it before retrying." : ""}`);
|
|
289
|
+
// Error messages from processes/RPC peers may contain arguments, code, or credentials.
|
|
290
|
+
const safeCode = typeof code === "number" ? ` (RPC ${code})` : ["ENOENT", "EACCES"].includes(String(code)) ? ` (${code})` : "";
|
|
291
|
+
throw new BlenderError(`Blender MCP failed during ${phase}${safeCode}. Check BLENDER_MCP_COMMAND, BLENDER_MCP_ARGS and the running Blender addon.`);
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
signal?.removeEventListener("abort", abort);
|
|
296
|
+
await client.close().catch(() => { });
|
|
297
|
+
await transport.close().catch(() => { });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async invoke(session, name, args) {
|
|
301
|
+
const tool = session.tools.find((tool) => tool.name === name);
|
|
302
|
+
if (!tool)
|
|
303
|
+
throw new BlenderError("The requested tool is not advertised by Blender MCP. Call blender_mcp_status with connect:true to inspect available tools.");
|
|
304
|
+
let valid;
|
|
305
|
+
try {
|
|
306
|
+
valid = new AjvJsonSchemaValidator().getValidator(tool.inputSchema)(args).valid;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
throw new BlenderError("Blender MCP advertised an unsupported input schema for this tool.");
|
|
310
|
+
}
|
|
311
|
+
if (!valid)
|
|
312
|
+
throw new BlenderError("Blender tool arguments do not match its advertised input schema. Inspect blender_mcp_status before calling the tool.");
|
|
313
|
+
session.markDispatched();
|
|
314
|
+
return normalizeToolFailure(await session.client.callTool({ name, arguments: args }, undefined, session.options()));
|
|
315
|
+
}
|
|
316
|
+
async status(args, signal) {
|
|
317
|
+
allowedArguments(args, ["connect", "timeout_ms"]);
|
|
318
|
+
const connect = booleanArgument(args.connect, "connect");
|
|
319
|
+
const timeout = timeoutMilliseconds(args.timeout_ms);
|
|
320
|
+
if (!connect)
|
|
321
|
+
return { configured: Boolean(process.env.BLENDER_MCP_COMMAND?.trim()), legacy_configured: Boolean(process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim()), connected: false, transport: "stdio" };
|
|
322
|
+
return this.session(timeout, signal, async ({ client, tools }) => ({ configured: true, connected: true, transport: "stdio", server: client.getServerVersion(), capabilities: client.getServerCapabilities(), tools }));
|
|
323
|
+
}
|
|
324
|
+
async call(args, signal) {
|
|
325
|
+
allowedArguments(args, ["tool_name", "arguments", "timeout_ms"]);
|
|
326
|
+
const name = textArgument(args.tool_name, "tool_name");
|
|
327
|
+
const toolArgs = argumentObject(args.arguments ?? {}, "arguments");
|
|
328
|
+
return this.session(timeoutMilliseconds(args.timeout_ms), signal, (session) => this.invoke(session, name, toolArgs));
|
|
329
|
+
}
|
|
330
|
+
async edit(args, legacyCommand, signal) {
|
|
331
|
+
allowedArguments(args, ["stl_path", "operations", "output_path", "user_prompt", "execute", "bridge_command", "timeout_ms"]);
|
|
332
|
+
const stlPath = textArgument(args.stl_path, "stl_path");
|
|
333
|
+
if (!Array.isArray(args.operations) || args.operations.length === 0 || args.operations.length > 64 || args.operations.some((entry) => typeof entry !== "string" || !entry.trim() || entry.includes("\0")))
|
|
334
|
+
throw new BlenderError("operations must contain 1 to 64 non-empty strings.");
|
|
335
|
+
const execute = booleanArgument(args.execute, "execute");
|
|
336
|
+
const timeout = timeoutMilliseconds(args.timeout_ms);
|
|
337
|
+
if (args.user_prompt !== undefined && typeof args.user_prompt !== "string")
|
|
338
|
+
throw new BlenderError("user_prompt must be a string.");
|
|
339
|
+
const command = legacyCommand || (!process.env.BLENDER_MCP_COMMAND?.trim() ? process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim() : undefined);
|
|
340
|
+
if (command || !process.env.BLENDER_MCP_COMMAND?.trim()) {
|
|
341
|
+
const payload = { stlPath, operations: args.operations, ...(args.output_path !== undefined ? { outputPath: textArgument(args.output_path, "output_path") } : {}) };
|
|
342
|
+
if (!execute)
|
|
343
|
+
return { status: "prepared", mode: "legacy", payload, note: "Legacy bridge payload only. Configure BLENDER_MCP_COMMAND and BLENDER_MCP_ARGS for standard MCP, or BLENDER_MCP_BRIDGE_COMMAND for a custom executable." };
|
|
344
|
+
if (!command)
|
|
345
|
+
throw new BlenderError("No Blender connection configured. Set BLENDER_MCP_COMMAND and BLENDER_MCP_ARGS, or a legacy BLENDER_MCP_BRIDGE_COMMAND executable.");
|
|
346
|
+
await inputPath(stlPath, "stl_path");
|
|
347
|
+
let output;
|
|
348
|
+
try {
|
|
349
|
+
output = await execFileAsync(command, [], { env: { ...blenderEnvironment(), MCP_BLENDER_PAYLOAD: JSON.stringify(payload) }, timeout, signal, maxBuffer: 8 * 1024 * 1024 });
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
throw new BlenderError("Legacy Blender bridge failed, timed out, or was cancelled. Check its executable and logs; execution may have partially completed.");
|
|
353
|
+
}
|
|
354
|
+
let parsed;
|
|
355
|
+
try {
|
|
356
|
+
parsed = JSON.parse(output.stdout);
|
|
357
|
+
}
|
|
358
|
+
catch { /* Legacy executables may return plain text. */ }
|
|
359
|
+
if (parsed?.isError === true || parsed?.ok === false || parsed?.success === false || parsed?.status === "error" || /^\s*(Error\b|Traceback\b)/i.test(output.stdout))
|
|
360
|
+
throw new BlenderError("Legacy Blender bridge reported an error; no edited output was verified.");
|
|
361
|
+
return { status: "executed", mode: "legacy", output_verified: false, stdout: output.stdout.trim(), stderr: output.stderr.trim(), ...(parsed !== undefined ? { bridge_result: parsed } : {}) };
|
|
362
|
+
}
|
|
363
|
+
const plan = await editPlan(args);
|
|
364
|
+
const code = editScript(plan);
|
|
365
|
+
if (!execute)
|
|
366
|
+
return { status: "prepared", mode: "mcp", output_path: plan.outputPath, payload: plan, code };
|
|
367
|
+
try {
|
|
368
|
+
const result = await this.session(timeout, signal, (session) => this.invoke(session, "execute_blender_code", { code, user_prompt: args.user_prompt ?? "" }));
|
|
369
|
+
if (result.isError)
|
|
370
|
+
throw new BlenderError("Blender MCP reported an editing error. No output was published.");
|
|
371
|
+
const text = result.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\n");
|
|
372
|
+
const receipts = [...text.matchAll(/BAMBU_STL_RESULT:(\{[^\n]*\})/g)];
|
|
373
|
+
let receipt;
|
|
374
|
+
try {
|
|
375
|
+
receipt = JSON.parse(receipts.at(-1)?.[1] ?? "null");
|
|
376
|
+
}
|
|
377
|
+
catch { /* Invalid receipt is a failure. */ }
|
|
378
|
+
if (receipt?.requestId !== plan.requestId || receipt?.outputPath !== plan.stagedOutputPath)
|
|
379
|
+
throw new BlenderError("Blender did not return a matching export receipt. Inspect its connection and tool result; no output was published.");
|
|
380
|
+
const metadata = await regularStl(plan.stagedOutputPath);
|
|
381
|
+
// link() publishes without replacing a file created since preflight; staging is on the same filesystem.
|
|
382
|
+
await fs.link(plan.stagedOutputPath, plan.outputPath).catch(() => { throw new BlenderError("Cannot publish the edited STL; output_path may already exist or be unwritable."); });
|
|
383
|
+
return { status: "success", mode: "mcp", output_path: plan.outputPath, ...metadata, output_verified: true, blender_result: result };
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await fs.rm(plan.stagedOutputPath, { force: true }).catch(() => { });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|