mcp-3d-printer-server 1.2.9 → 1.2.12
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/CHANGELOG.md +222 -0
- package/CONTRIBUTORS.md +56 -0
- package/README.md +528 -811
- package/dist/3mf_parser.js +8 -8
- package/dist/blender-mcp-bridge.d.ts +1 -0
- package/dist/blender-mcp-bridge.js +209 -21
- package/dist/fulu-orca.js +29 -5
- package/dist/index.js +614 -110
- package/dist/printers/bambu-node-guard.d.ts +2 -0
- package/dist/printers/bambu-node-guard.js +38 -0
- package/dist/printers/bambu.d.ts +53 -7
- package/dist/printers/bambu.js +420 -126
- package/dist/printers/creality.d.ts +15 -6
- package/dist/printers/creality.js +42 -9
- package/dist/printers/duet.d.ts +11 -6
- package/dist/printers/duet.js +57 -34
- package/dist/printers/generic-base.d.ts +69 -0
- package/dist/printers/generic-base.js +209 -0
- package/dist/printers/klipper.d.ts +17 -6
- package/dist/printers/klipper.js +63 -16
- package/dist/printers/octoprint.d.ts +18 -6
- package/dist/printers/octoprint.js +64 -9
- package/dist/printers/printer-factory.d.ts +11 -2
- package/dist/printers/printer-factory.js +9 -8
- package/dist/printers/prusa.d.ts +14 -6
- package/dist/printers/prusa.js +56 -9
- package/dist/printers/repetier.d.ts +18 -6
- package/dist/printers/repetier.js +48 -6
- package/dist/safety/archive.d.ts +11 -0
- package/dist/safety/archive.js +214 -0
- package/dist/safety/artifact.d.ts +6 -0
- package/dist/safety/artifact.js +66 -0
- package/dist/safety/confirmation.d.ts +22 -0
- package/dist/safety/confirmation.js +43 -0
- package/dist/safety/expected-peaks.d.ts +24 -0
- package/dist/safety/expected-peaks.js +24 -0
- package/dist/safety/gcode-thermal.d.ts +64 -0
- package/dist/safety/gcode-thermal.js +382 -0
- package/dist/safety/limits.d.ts +31 -0
- package/dist/safety/limits.js +105 -0
- package/dist/safety/print-file.d.ts +25 -0
- package/dist/safety/print-file.js +610 -0
- package/dist/safety/printer-state.d.ts +51 -0
- package/dist/safety/printer-state.js +371 -0
- package/dist/slicer/bambu-cli.d.ts +110 -0
- package/dist/slicer/bambu-cli.js +547 -0
- package/dist/slicer/profile-flatten.d.ts +104 -0
- package/dist/slicer/profile-flatten.js +676 -0
- package/dist/slicer/run-slicer.d.ts +18 -0
- package/dist/slicer/run-slicer.js +102 -0
- package/dist/slicer/slicer-error.d.ts +27 -0
- package/dist/slicer/slicer-error.js +53 -0
- package/dist/slicer/templates.d.ts +126 -0
- package/dist/slicer/templates.js +255 -0
- package/dist/stl/stl-manipulator.d.ts +25 -3
- package/dist/stl/stl-manipulator.js +214 -232
- package/package.json +16 -9
- package/server.json +56 -0
- package/src/3mf_parser.ts +8 -8
- package/src/blender-mcp-bridge.ts +200 -19
- package/src/fulu-orca.ts +36 -6
- package/src/index.ts +686 -137
- package/src/printers/bambu-node-guard.ts +44 -0
- package/src/printers/bambu.ts +458 -164
- package/src/printers/creality.ts +40 -9
- package/src/printers/duet.ts +64 -53
- package/src/printers/generic-base.ts +286 -0
- package/src/printers/klipper.ts +83 -28
- package/src/printers/octoprint.ts +74 -21
- package/src/printers/printer-factory.ts +21 -11
- package/src/printers/prusa.ts +53 -10
- package/src/printers/repetier.ts +55 -17
- package/src/safety/archive.ts +181 -0
- package/src/safety/artifact.ts +58 -0
- package/src/safety/confirmation.ts +56 -0
- package/src/safety/expected-peaks.ts +47 -0
- package/src/safety/gcode-thermal.ts +404 -0
- package/src/safety/limits.ts +98 -0
- package/src/safety/print-file.ts +429 -0
- package/src/safety/printer-state.ts +344 -0
- package/src/slicer/bambu-cli.ts +633 -0
- package/src/slicer/profile-flatten.ts +830 -0
- package/src/slicer/run-slicer.ts +126 -0
- package/src/slicer/slicer-error.ts +72 -0
- package/src/slicer/templates.ts +288 -0
- package/src/stl/stl-manipulator.ts +247 -275
package/dist/3mf_parser.js
CHANGED
|
@@ -85,7 +85,7 @@ async function parseBambuConfig(zip) {
|
|
|
85
85
|
if (file) {
|
|
86
86
|
configFile = file;
|
|
87
87
|
configContent = await file.async('string');
|
|
88
|
-
console.
|
|
88
|
+
console.error(`Found Bambu config file: ${configFile.name}`);
|
|
89
89
|
break; // Use the first one found (project usually has the most)
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -94,7 +94,7 @@ async function parseBambuConfig(zip) {
|
|
|
94
94
|
// Attempt to parse as JSON
|
|
95
95
|
const jsonData = JSON.parse(configContent);
|
|
96
96
|
const parsedConfig = parseBambuJSONConfig(jsonData);
|
|
97
|
-
console.
|
|
97
|
+
console.error('Bambu config parsed successfully as JSON.');
|
|
98
98
|
return parsedConfig;
|
|
99
99
|
}
|
|
100
100
|
catch (jsonError) {
|
|
@@ -102,7 +102,7 @@ async function parseBambuConfig(zip) {
|
|
|
102
102
|
// Fallback: Try parsing as INI if JSON fails (though unlikely based on sample)
|
|
103
103
|
try {
|
|
104
104
|
const parsedConfig = parseFallbackINIConfig(configContent); // Keep a simple INI parser as fallback
|
|
105
|
-
console.
|
|
105
|
+
console.error('Bambu config parsed successfully as INI (fallback).');
|
|
106
106
|
return parsedConfig;
|
|
107
107
|
}
|
|
108
108
|
catch (iniError) {
|
|
@@ -112,7 +112,7 @@ async function parseBambuConfig(zip) {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
else {
|
|
115
|
-
console.
|
|
115
|
+
console.error('No Bambu-specific config file found in Metadata directory.');
|
|
116
116
|
return {};
|
|
117
117
|
}
|
|
118
118
|
}
|
|
@@ -181,7 +181,7 @@ async function parse3DModelConfig(zip) {
|
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
|
-
console.
|
|
184
|
+
console.error('3dmodel.model parsed successfully.');
|
|
185
185
|
return { metadata, objects, build: { items: buildItems } };
|
|
186
186
|
}
|
|
187
187
|
catch (error) {
|
|
@@ -190,13 +190,13 @@ async function parse3DModelConfig(zip) {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
export async function parse3MF(filePath) {
|
|
193
|
-
console.
|
|
193
|
+
console.error(`Parsing 3MF file: ${filePath}`);
|
|
194
194
|
try {
|
|
195
195
|
const data = await fs.readFile(filePath);
|
|
196
196
|
const zip = await JSZip.loadAsync(data);
|
|
197
197
|
// List files for debugging
|
|
198
198
|
// const fileList = Object.keys(zip.files);
|
|
199
|
-
// console.
|
|
199
|
+
// console.error('Files in 3MF:', fileList);
|
|
200
200
|
const { metadata, objects, build } = await parse3DModelConfig(zip);
|
|
201
201
|
const bambuConfig = await parseBambuConfig(zip);
|
|
202
202
|
// Combine data into the final structure
|
|
@@ -206,7 +206,7 @@ export async function parse3MF(filePath) {
|
|
|
206
206
|
build,
|
|
207
207
|
slicerConfig: bambuConfig
|
|
208
208
|
};
|
|
209
|
-
console.
|
|
209
|
+
console.error('3MF parsing completed.');
|
|
210
210
|
return combinedData;
|
|
211
211
|
}
|
|
212
212
|
catch (error) {
|
|
@@ -4,6 +4,7 @@ export declare class BlenderMcpBridge {
|
|
|
4
4
|
private session;
|
|
5
5
|
private invoke;
|
|
6
6
|
status(args: Arguments, signal?: AbortSignal): Promise<unknown>;
|
|
7
|
+
exportStl(args: Arguments, signal?: AbortSignal): Promise<unknown>;
|
|
7
8
|
call(args: Arguments, signal?: AbortSignal): Promise<CallToolResult>;
|
|
8
9
|
edit(args: Arguments, legacyCommand?: string, signal?: AbortSignal): Promise<unknown>;
|
|
9
10
|
}
|
|
@@ -13,6 +13,7 @@ const execAsync = promisify(exec);
|
|
|
13
13
|
const MAX_STL_BYTES = 256 * 1024 * 1024;
|
|
14
14
|
const MAX_ASCII_STL_BYTES = 4 * 1024 * 1024;
|
|
15
15
|
const RECEIPT_PREFIX = "BAMBU_STL_RESULT:";
|
|
16
|
+
const EXPORT_RECEIPT_PREFIX = "MCP3D_STL_EXPORT:";
|
|
16
17
|
class BlenderError extends Error {
|
|
17
18
|
}
|
|
18
19
|
function editDeadline(timeout, signal) {
|
|
@@ -65,7 +66,7 @@ function timeoutMilliseconds(value) {
|
|
|
65
66
|
function standardConfiguration() {
|
|
66
67
|
const command = process.env.BLENDER_MCP_COMMAND?.trim();
|
|
67
68
|
if (!command)
|
|
68
|
-
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
|
|
69
|
+
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 [\"mcp-for-blender\"]; the older blender-mcp package name still works). Open Blender with the matching addon connected; the addon does not run in background mode.");
|
|
69
70
|
let args;
|
|
70
71
|
try {
|
|
71
72
|
args = JSON.parse(process.env.BLENDER_MCP_ARGS?.trim() || "[]");
|
|
@@ -109,6 +110,14 @@ function normalizeToolFailure(result) {
|
|
|
109
110
|
});
|
|
110
111
|
return failed ? { ...result, isError: true } : result;
|
|
111
112
|
}
|
|
113
|
+
function boundingBox(min, max) {
|
|
114
|
+
const round = (value) => Math.round(value * 1e4) / 1e4;
|
|
115
|
+
return {
|
|
116
|
+
min: [round(min[0]), round(min[1]), round(min[2])],
|
|
117
|
+
max: [round(max[0]), round(max[1]), round(max[2])],
|
|
118
|
+
dimensions: [round(max[0] - min[0]), round(max[1] - min[1]), round(max[2] - min[2])],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
112
121
|
async function regularStl(filePath, deadline) {
|
|
113
122
|
deadline.check();
|
|
114
123
|
const stat = await fs.lstat(filePath).catch(() => { throw new BlenderError("STL file does not exist or is not readable."); });
|
|
@@ -140,18 +149,26 @@ async function regularStl(filePath, deadline) {
|
|
|
140
149
|
// Validate binary records directly; constructing a Three.js geometry would
|
|
141
150
|
// expand a 256 MiB input into hundreds of MiB of positions/normals/colors.
|
|
142
151
|
const chunk = Buffer.alloc(50 * 1024);
|
|
152
|
+
const min = [Infinity, Infinity, Infinity];
|
|
153
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
143
154
|
for (let position = 84; position < stat.size; position += chunk.length) {
|
|
144
155
|
const length = Math.min(chunk.length, stat.size - position);
|
|
145
156
|
await read(chunk, position, length);
|
|
146
157
|
for (let face = 0; face < length; face += 50) {
|
|
147
158
|
for (let coordinate = 12; coordinate < 48; coordinate += 4) {
|
|
148
|
-
|
|
159
|
+
const value = chunk.readFloatLE(face + coordinate);
|
|
160
|
+
if (!Number.isFinite(value))
|
|
149
161
|
throw new Error("invalid vertices");
|
|
162
|
+
const axis = ((coordinate - 12) / 4) % 3;
|
|
163
|
+
if (value < min[axis])
|
|
164
|
+
min[axis] = value;
|
|
165
|
+
if (value > max[axis])
|
|
166
|
+
max[axis] = value;
|
|
150
167
|
}
|
|
151
168
|
}
|
|
152
169
|
}
|
|
153
170
|
deadline.check();
|
|
154
|
-
return { bytes: stat.size, triangles };
|
|
171
|
+
return { bytes: stat.size, triangles, bounding_box: boundingBox(min, max) };
|
|
155
172
|
}
|
|
156
173
|
// Reject malformed binary headers before a parser can allocate arrays from
|
|
157
174
|
// an untrusted triangle count. ASCII parsing has its own smaller bound.
|
|
@@ -169,8 +186,10 @@ async function regularStl(filePath, deadline) {
|
|
|
169
186
|
const positions = geometry.getAttribute("position");
|
|
170
187
|
if (!positions || positions.count < 3 || positions.count % 3 !== 0 || !positions.array.every(Number.isFinite))
|
|
171
188
|
throw new Error("invalid vertices");
|
|
189
|
+
geometry.computeBoundingBox();
|
|
190
|
+
const box = geometry.boundingBox;
|
|
172
191
|
deadline.check();
|
|
173
|
-
return { bytes: stat.size, triangles: positions.count / 3 };
|
|
192
|
+
return { bytes: stat.size, triangles: positions.count / 3, bounding_box: boundingBox(box.min.toArray(), box.max.toArray()) };
|
|
174
193
|
}
|
|
175
194
|
catch (error) {
|
|
176
195
|
if (error instanceof BlenderError)
|
|
@@ -193,20 +212,7 @@ async function inputPath(value, label, deadline) {
|
|
|
193
212
|
}
|
|
194
213
|
async function editPlan(args, deadline) {
|
|
195
214
|
const stlPath = await inputPath(args.stl_path, "stl_path", deadline);
|
|
196
|
-
const
|
|
197
|
-
? path.join(path.dirname(stlPath), `model-edited-${randomUUID()}.stl`)
|
|
198
|
-
: path.resolve(textArgument(args.output_path, "output_path"));
|
|
199
|
-
if (path.extname(requestedOutput).toLowerCase() !== ".stl")
|
|
200
|
-
throw new BlenderError("output_path must name a new STL file.");
|
|
201
|
-
const directory = await fs.realpath(path.dirname(requestedOutput)).catch(() => { throw new BlenderError("output_path parent directory must already exist."); });
|
|
202
|
-
const outputPath = path.join(directory, path.basename(requestedOutput));
|
|
203
|
-
const existing = await fs.lstat(outputPath).catch((error) => {
|
|
204
|
-
if (error.code !== "ENOENT")
|
|
205
|
-
throw new BlenderError("output_path cannot be accessed.");
|
|
206
|
-
return undefined;
|
|
207
|
-
});
|
|
208
|
-
if (existing)
|
|
209
|
-
throw new BlenderError("output_path already exists; choose a new file. Input files are never overwritten.");
|
|
215
|
+
const { outputPath, directory } = await newStlOutput(args.output_path ?? path.join(path.dirname(stlPath), `model-edited-${randomUUID()}.stl`), deadline);
|
|
210
216
|
const operations = [];
|
|
211
217
|
for (const operation of args.operations) {
|
|
212
218
|
deadline.check();
|
|
@@ -229,6 +235,120 @@ async function editPlan(args, deadline) {
|
|
|
229
235
|
const requestId = randomUUID();
|
|
230
236
|
return { requestId, stlPath, outputPath, stagedOutputPath: path.join(directory, `.bambu-${requestId}.stl`), operations };
|
|
231
237
|
}
|
|
238
|
+
async function newStlOutput(value, deadline) {
|
|
239
|
+
deadline.check();
|
|
240
|
+
const requestedOutput = path.resolve(textArgument(value, "output_path"));
|
|
241
|
+
if (path.extname(requestedOutput).toLowerCase() !== ".stl")
|
|
242
|
+
throw new BlenderError("output_path must name a new STL file.");
|
|
243
|
+
const directory = await fs.realpath(path.dirname(requestedOutput)).catch(() => { throw new BlenderError("output_path parent directory must already exist."); });
|
|
244
|
+
const outputPath = path.join(directory, path.basename(requestedOutput));
|
|
245
|
+
const existing = await fs.lstat(outputPath).catch((error) => {
|
|
246
|
+
if (error.code !== "ENOENT")
|
|
247
|
+
throw new BlenderError("output_path cannot be accessed.");
|
|
248
|
+
return undefined;
|
|
249
|
+
});
|
|
250
|
+
if (existing)
|
|
251
|
+
throw new BlenderError("output_path already exists; choose a new file. Existing files are never overwritten.");
|
|
252
|
+
return { outputPath, directory };
|
|
253
|
+
}
|
|
254
|
+
async function exportPlan(args, deadline) {
|
|
255
|
+
const names = args.object_names;
|
|
256
|
+
if (!Array.isArray(names) || names.length === 0 || names.length > 64 ||
|
|
257
|
+
names.some((name) => typeof name !== "string" || !name.trim() || name.length > 256 || name.includes("\0")) ||
|
|
258
|
+
new Set(names).size !== names.length) {
|
|
259
|
+
throw new BlenderError("object_names must list 1 to 64 distinct Blender object names.");
|
|
260
|
+
}
|
|
261
|
+
const scale = args.scale ?? 1;
|
|
262
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0 || scale > 1000000)
|
|
263
|
+
throw new BlenderError("scale must be a finite number greater than 0.");
|
|
264
|
+
const { outputPath, directory } = await newStlOutput(args.output_path, deadline);
|
|
265
|
+
const requestId = randomUUID();
|
|
266
|
+
return { requestId, objectNames: names, outputPath, stagedOutputPath: path.join(directory, `.mcp3d-${requestId}.stl`),
|
|
267
|
+
applyModifiers: booleanArgument(args.apply_modifiers, "apply_modifiers", true), scale };
|
|
268
|
+
}
|
|
269
|
+
function exportScript(plan) {
|
|
270
|
+
// Writes world-space triangles directly instead of calling an exporter, so the
|
|
271
|
+
// user's selection, active object and mode are never changed. JSON is data only.
|
|
272
|
+
return `# MCP3D_EXPORT_PLAN ${JSON.stringify(plan)}
|
|
273
|
+
import bpy
|
|
274
|
+
import json
|
|
275
|
+
import math
|
|
276
|
+
import os
|
|
277
|
+
import struct
|
|
278
|
+
|
|
279
|
+
def _mcp3d_export():
|
|
280
|
+
plan = json.loads(${JSON.stringify(JSON.stringify(plan))})
|
|
281
|
+
if bpy.context.mode != 'OBJECT':
|
|
282
|
+
raise RuntimeError('Switch Blender to Object Mode before exporting an STL')
|
|
283
|
+
objects = []
|
|
284
|
+
for name in plan['objectNames']:
|
|
285
|
+
obj = bpy.data.objects.get(name)
|
|
286
|
+
if obj is None:
|
|
287
|
+
raise RuntimeError('No Blender object named ' + json.dumps(name))
|
|
288
|
+
if obj.type not in {'MESH', 'CURVE', 'SURFACE', 'META', 'FONT'}:
|
|
289
|
+
raise RuntimeError(json.dumps(name) + ' is a ' + obj.type + ' object, not printable geometry')
|
|
290
|
+
if not plan['applyModifiers'] and obj.type != 'MESH':
|
|
291
|
+
raise RuntimeError(json.dumps(name) + ' needs apply_modifiers to be converted to a mesh')
|
|
292
|
+
objects.append(obj)
|
|
293
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
294
|
+
scale = float(plan['scale'])
|
|
295
|
+
lower = [math.inf] * 3
|
|
296
|
+
upper = [-math.inf] * 3
|
|
297
|
+
triangles = 0
|
|
298
|
+
exported = []
|
|
299
|
+
finished = False
|
|
300
|
+
try:
|
|
301
|
+
with open(plan['stagedOutputPath'], 'xb') as handle:
|
|
302
|
+
handle.write(b'mcp-3d-printer-server Blender STL export'.ljust(80, b' '))
|
|
303
|
+
handle.write(struct.pack('<I', 0))
|
|
304
|
+
for obj in objects:
|
|
305
|
+
evaluated = obj.evaluated_get(depsgraph)
|
|
306
|
+
matrix = evaluated.matrix_world
|
|
307
|
+
owner = evaluated if plan['applyModifiers'] else None
|
|
308
|
+
mesh = evaluated.to_mesh() if owner else obj.data.copy()
|
|
309
|
+
try:
|
|
310
|
+
mesh.calc_loop_triangles()
|
|
311
|
+
mirrored = matrix.determinant() < 0
|
|
312
|
+
count = 0
|
|
313
|
+
for tri in mesh.loop_triangles:
|
|
314
|
+
points = [matrix @ mesh.vertices[index].co * scale for index in tri.vertices]
|
|
315
|
+
if mirrored:
|
|
316
|
+
points = [points[0], points[2], points[1]]
|
|
317
|
+
for point in points:
|
|
318
|
+
for axis in range(3):
|
|
319
|
+
value = point[axis]
|
|
320
|
+
if not math.isfinite(value):
|
|
321
|
+
raise RuntimeError('Non-finite vertex in ' + json.dumps(obj.name))
|
|
322
|
+
lower[axis] = min(lower[axis], value)
|
|
323
|
+
upper[axis] = max(upper[axis], value)
|
|
324
|
+
normal = (points[1] - points[0]).cross(points[2] - points[0])
|
|
325
|
+
normal = normal.normalized() if normal.length > 0 else normal
|
|
326
|
+
handle.write(struct.pack('<12fH', *normal, *points[0], *points[1], *points[2], 0))
|
|
327
|
+
count += 1
|
|
328
|
+
triangles += count
|
|
329
|
+
exported.append({'name': obj.name, 'type': obj.type, 'triangles': count})
|
|
330
|
+
finally:
|
|
331
|
+
if owner:
|
|
332
|
+
owner.to_mesh_clear()
|
|
333
|
+
else:
|
|
334
|
+
bpy.data.meshes.remove(mesh)
|
|
335
|
+
if triangles == 0:
|
|
336
|
+
raise RuntimeError('The selected objects contain no triangles')
|
|
337
|
+
handle.seek(80)
|
|
338
|
+
handle.write(struct.pack('<I', triangles))
|
|
339
|
+
finished = True
|
|
340
|
+
finally:
|
|
341
|
+
if not finished and os.path.exists(plan['stagedOutputPath']):
|
|
342
|
+
os.remove(plan['stagedOutputPath'])
|
|
343
|
+
units = bpy.context.scene.unit_settings
|
|
344
|
+
print('${EXPORT_RECEIPT_PREFIX}' + json.dumps({'requestId': plan['requestId'], 'outputPath': plan['stagedOutputPath'],
|
|
345
|
+
'triangles': triangles, 'min': lower, 'max': upper, 'objects': exported,
|
|
346
|
+
'sceneUnits': {'system': units.system, 'scaleLength': units.scale_length, 'lengthUnit': units.length_unit}}))
|
|
347
|
+
|
|
348
|
+
_mcp3d_export()
|
|
349
|
+
del _mcp3d_export
|
|
350
|
+
`;
|
|
351
|
+
}
|
|
232
352
|
function editScript(plan) {
|
|
233
353
|
// JSON is data, never interpolated Python syntax. The comment is also useful in dry runs.
|
|
234
354
|
return `# BAMBU_EDIT_PLAN ${JSON.stringify(plan)}
|
|
@@ -381,17 +501,85 @@ export class BlenderMcpBridge {
|
|
|
381
501
|
throw new BlenderError("Blender MCP advertised an unsupported input schema for this tool.");
|
|
382
502
|
}
|
|
383
503
|
if (!valid)
|
|
384
|
-
throw new BlenderError("Blender tool arguments do not match its advertised input schema.
|
|
504
|
+
throw new BlenderError("Blender tool arguments do not match its advertised input schema. Call blender_mcp_status with connect: true and tool_names: [\"<tool>\"] to read the schema.");
|
|
385
505
|
session.markDispatched();
|
|
386
506
|
return normalizeToolFailure(await session.client.callTool({ name, arguments: args }, undefined, session.options()));
|
|
387
507
|
}
|
|
388
508
|
async status(args, signal) {
|
|
389
|
-
allowedArguments(args, ["connect", "timeout_ms"]);
|
|
509
|
+
allowedArguments(args, ["connect", "timeout_ms", "tool_names", "include_schemas"]);
|
|
390
510
|
const connect = booleanArgument(args.connect, "connect");
|
|
391
511
|
const timeout = timeoutMilliseconds(args.timeout_ms);
|
|
512
|
+
const includeSchemas = booleanArgument(args.include_schemas, "include_schemas");
|
|
513
|
+
const requested = args.tool_names;
|
|
514
|
+
if (requested !== undefined && (!Array.isArray(requested) || requested.length > 64 || requested.some((name) => typeof name !== "string" || !name.trim()))) {
|
|
515
|
+
throw new BlenderError("tool_names must be an array of up to 64 tool names.");
|
|
516
|
+
}
|
|
392
517
|
if (!connect)
|
|
393
518
|
return { configured: Boolean(process.env.BLENDER_MCP_COMMAND?.trim()), legacy_configured: Boolean(process.env.BLENDER_MCP_BRIDGE_COMMAND?.trim()), connected: false, transport: "stdio" };
|
|
394
|
-
return this.session(timeout, signal, async ({ client, tools }) =>
|
|
519
|
+
return this.session(timeout, signal, async ({ client, tools }) => {
|
|
520
|
+
// Full Blender MCP schemas are tens of kilobytes; list names by default and
|
|
521
|
+
// return complete definitions only for the tools an agent is about to call.
|
|
522
|
+
const wanted = new Set(requested);
|
|
523
|
+
const unknown = [...wanted].filter((name) => !tools.some((tool) => tool.name === name));
|
|
524
|
+
const summary = (tool) => ({ name: tool.name, description: tool.description?.trim().split(/\n\s*\n/)[0].replace(/\s+/g, " ").slice(0, 300) });
|
|
525
|
+
return {
|
|
526
|
+
configured: true, connected: true, transport: "stdio", server: client.getServerVersion(), capabilities: client.getServerCapabilities(),
|
|
527
|
+
tools: tools.map((tool) => includeSchemas || wanted.has(tool.name) ? tool : summary(tool)),
|
|
528
|
+
schemas_included: includeSchemas ? "all" : [...wanted].filter((name) => !unknown.includes(name)),
|
|
529
|
+
...(unknown.length ? { unknown_tool_names: unknown } : {}),
|
|
530
|
+
note: "Pass tool_names (or include_schemas: true) to receive full input schemas. Verified STL export: blender_mcp_export_stl.",
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
async exportStl(args, signal) {
|
|
535
|
+
allowedArguments(args, ["object_names", "output_path", "apply_modifiers", "scale", "user_prompt", "timeout_ms"]);
|
|
536
|
+
const timeout = timeoutMilliseconds(args.timeout_ms);
|
|
537
|
+
const deadline = editDeadline(timeout, signal);
|
|
538
|
+
if (args.user_prompt !== undefined && typeof args.user_prompt !== "string")
|
|
539
|
+
throw new BlenderError("user_prompt must be a string.");
|
|
540
|
+
standardConfiguration();
|
|
541
|
+
const plan = await exportPlan(args, deadline);
|
|
542
|
+
const code = exportScript(plan);
|
|
543
|
+
try {
|
|
544
|
+
const result = await this.session(deadline.remaining(), signal, (session) => this.invoke(session, "execute_blender_code", { code, user_prompt: args.user_prompt ?? "" }));
|
|
545
|
+
if (result.isError) {
|
|
546
|
+
const detail = result.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\n");
|
|
547
|
+
// mcp-for-blender wraps the Python exception as JSON; lead with its message.
|
|
548
|
+
let reason = "";
|
|
549
|
+
try {
|
|
550
|
+
reason = JSON.parse(`"${detail.match(/"message":\s*"((?:[^"\\]|\\.)*)"/)?.[1] ?? ""}"`);
|
|
551
|
+
}
|
|
552
|
+
catch { /* Fall back to the raw text. */ }
|
|
553
|
+
throw new BlenderError(`Blender could not export the requested objects: ${reason || detail.slice(0, 600) || "no details"}. No output was published.`);
|
|
554
|
+
}
|
|
555
|
+
const text = result.content.filter((entry) => entry.type === "text").map((entry) => entry.text).join("\n");
|
|
556
|
+
const receipts = [...text.matchAll(/MCP3D_STL_EXPORT:(\{[^\n]*\})/g)];
|
|
557
|
+
let receipt;
|
|
558
|
+
try {
|
|
559
|
+
receipt = JSON.parse(receipts.at(-1)?.[1] ?? "null");
|
|
560
|
+
}
|
|
561
|
+
catch { /* Invalid receipt is a failure. */ }
|
|
562
|
+
if (receipt?.requestId !== plan.requestId || receipt?.outputPath !== plan.stagedOutputPath)
|
|
563
|
+
throw new BlenderError("Blender did not return a matching export receipt. Inspect its connection and tool result; no output was published.");
|
|
564
|
+
const metadata = await regularStl(plan.stagedOutputPath, deadline);
|
|
565
|
+
if (metadata.triangles !== receipt.triangles)
|
|
566
|
+
throw new BlenderError("The exported STL does not match Blender's export receipt; no output was published.");
|
|
567
|
+
deadline.check();
|
|
568
|
+
await fs.link(plan.stagedOutputPath, plan.outputPath).catch(() => { throw new BlenderError("Cannot publish the exported STL; output_path may already exist or be unwritable."); });
|
|
569
|
+
const largest = Math.max(...metadata.bounding_box.dimensions);
|
|
570
|
+
const warnings = [];
|
|
571
|
+
if (largest < 1)
|
|
572
|
+
warnings.push("The model is under 1 unit across. Slicers read STL coordinates as millimetres; if it was modelled in metres, export again with scale: 1000.");
|
|
573
|
+
if (largest > 2000)
|
|
574
|
+
warnings.push("The model is over 2000 units across. Slicers read STL coordinates as millimetres; check the scene units and scale.");
|
|
575
|
+
return {
|
|
576
|
+
status: "success", mode: "mcp", output_path: plan.outputPath, ...metadata, units: "STL coordinates (slicers treat 1 unit as 1 mm)",
|
|
577
|
+
scale: plan.scale, objects: receipt.objects, scene_units: receipt.sceneUnits, output_verified: true, ...(warnings.length ? { warnings } : {}),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
finally {
|
|
581
|
+
await fs.rm(plan.stagedOutputPath, { force: true }).catch(() => { });
|
|
582
|
+
}
|
|
395
583
|
}
|
|
396
584
|
async call(args, signal) {
|
|
397
585
|
allowedArguments(args, ["tool_name", "arguments", "timeout_ms"]);
|
package/dist/fulu-orca.js
CHANGED
|
@@ -28,6 +28,22 @@ const FULU_PRINT_RPC_METHODS = new Set([
|
|
|
28
28
|
"net.start_send_gcode_to_sdcard",
|
|
29
29
|
"net.start_sdcard_print",
|
|
30
30
|
]);
|
|
31
|
+
/**
|
|
32
|
+
* Agent/session/account bootstrap methods. They do not address a printer, so
|
|
33
|
+
* they remain available with allow_mutating_method=true. Every other method
|
|
34
|
+
* outside the read-only allowlist is refused: raw print starts, raw MQTT
|
|
35
|
+
* messages, file transfers and unknown methods would bypass the inspected
|
|
36
|
+
* print, heating and printer-state gate.
|
|
37
|
+
*/
|
|
38
|
+
const SESSION_MUTATING_METHODS = new Set([
|
|
39
|
+
"net.create_agent",
|
|
40
|
+
"net.start",
|
|
41
|
+
"net.init_log",
|
|
42
|
+
"net.set_config_dir",
|
|
43
|
+
"net.set_country_code",
|
|
44
|
+
"net.connect_server",
|
|
45
|
+
"net.change_user",
|
|
46
|
+
]);
|
|
31
47
|
export function isFuluPrintRpcMethod(method) {
|
|
32
48
|
return FULU_PRINT_RPC_METHODS.has(method.trim());
|
|
33
49
|
}
|
|
@@ -370,13 +386,18 @@ export async function invokeFuluBridgeRpc(options) {
|
|
|
370
386
|
}
|
|
371
387
|
const readOnly = isReadOnlyBridgeMethod(method);
|
|
372
388
|
const printMethod = isFuluPrintRpcMethod(method);
|
|
389
|
+
if (printMethod) {
|
|
390
|
+
throw new Error(`Print safety: raw FULU bridge print method "${method}" is disabled because it would start an uninspected job ` +
|
|
391
|
+
"without the thermal, model, nozzle, printer-state and human-confirmation checks. Use print_3mf for a checked LAN print.");
|
|
392
|
+
}
|
|
393
|
+
if (!readOnly && !SESSION_MUTATING_METHODS.has(method)) {
|
|
394
|
+
throw new Error(`Print safety: raw FULU bridge method "${method}" is not in the verified allowlist. Raw printer messages, ` +
|
|
395
|
+
"file transfers and unknown methods could bypass the print and heating safety gate. Use the dedicated printer tools instead.");
|
|
396
|
+
}
|
|
373
397
|
if (!readOnly && !options.allowMutatingMethod) {
|
|
374
|
-
throw new Error(`FULU bridge method "${method}" may mutate
|
|
398
|
+
throw new Error(`FULU bridge method "${method}" may mutate account, session, or cloud state. ` +
|
|
375
399
|
"Pass allow_mutating_method=true only when you intend to perform that action.");
|
|
376
400
|
}
|
|
377
|
-
if (printMethod && !options.bambuModel?.trim()) {
|
|
378
|
-
throw new Error("bambu_model is required for FULU BambuNetwork print RPC methods.");
|
|
379
|
-
}
|
|
380
401
|
const command = options.bridgeCommand || process.env.FULU_BAMBU_BRIDGE_COMMAND || "";
|
|
381
402
|
const bridgeResult = await runBridgeRequests(command, [{ id: 1, method, payload: options.payload || {} }], options.timeoutMs || 5000);
|
|
382
403
|
return {
|
|
@@ -441,11 +462,14 @@ export async function inspectFuluOrcaSetup(options) {
|
|
|
441
462
|
safeByDefault: true,
|
|
442
463
|
readOnlyMethods: Array.from(READ_ONLY_BRIDGE_METHODS),
|
|
443
464
|
readOnlyNetPrefixes: READ_ONLY_NET_PREFIXES,
|
|
444
|
-
|
|
465
|
+
sessionMethodsRequireAllowMutating: Array.from(SESSION_MUTATING_METHODS),
|
|
466
|
+
disabledPrintMethods: Array.from(FULU_PRINT_RPC_METHODS),
|
|
467
|
+
otherMethods: "disabled: raw printer messages, file transfers and unknown methods bypass the safety gate",
|
|
445
468
|
},
|
|
446
469
|
notes: [
|
|
447
470
|
"Slicing support uses FULU OrcaSlicer-bambulab CLI output as a sliced 3MF.",
|
|
448
471
|
"Bambu print start still enforces BAMBU_MODEL before generating or sending project G-code.",
|
|
472
|
+
"Raw bridge print methods are disabled; print_3mf inspects the exact plate, checks the live printer and asks for human confirmation.",
|
|
449
473
|
"The FULU BambuNetwork bridge is a separate runtime from the LAN MQTT/FTPS path; use bridge probing to verify that runtime before relying on cloud behavior.",
|
|
450
474
|
"FULU macOS release bundles may ship the bridge dylib and host shims before the Linux plugin .so payload is present; missing linux-plugin entries mean the slicer can still be tested, but the BambuNetwork bridge is not fully ready.",
|
|
451
475
|
],
|