mcp-3d-printer-server 1.2.9 → 1.2.11

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 (86) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/CONTRIBUTORS.md +49 -0
  3. package/README.md +528 -811
  4. package/dist/3mf_parser.js +8 -8
  5. package/dist/blender-mcp-bridge.d.ts +1 -0
  6. package/dist/blender-mcp-bridge.js +209 -21
  7. package/dist/fulu-orca.js +29 -5
  8. package/dist/index.js +614 -110
  9. package/dist/printers/bambu-node-guard.d.ts +2 -0
  10. package/dist/printers/bambu-node-guard.js +38 -0
  11. package/dist/printers/bambu.d.ts +53 -7
  12. package/dist/printers/bambu.js +420 -126
  13. package/dist/printers/creality.d.ts +15 -6
  14. package/dist/printers/creality.js +42 -9
  15. package/dist/printers/duet.d.ts +11 -6
  16. package/dist/printers/duet.js +57 -34
  17. package/dist/printers/generic-base.d.ts +69 -0
  18. package/dist/printers/generic-base.js +209 -0
  19. package/dist/printers/klipper.d.ts +17 -6
  20. package/dist/printers/klipper.js +63 -16
  21. package/dist/printers/octoprint.d.ts +18 -6
  22. package/dist/printers/octoprint.js +64 -9
  23. package/dist/printers/printer-factory.d.ts +11 -2
  24. package/dist/printers/printer-factory.js +9 -8
  25. package/dist/printers/prusa.d.ts +14 -6
  26. package/dist/printers/prusa.js +56 -9
  27. package/dist/printers/repetier.d.ts +18 -6
  28. package/dist/printers/repetier.js +48 -6
  29. package/dist/safety/archive.d.ts +11 -0
  30. package/dist/safety/archive.js +214 -0
  31. package/dist/safety/artifact.d.ts +6 -0
  32. package/dist/safety/artifact.js +66 -0
  33. package/dist/safety/confirmation.d.ts +22 -0
  34. package/dist/safety/confirmation.js +43 -0
  35. package/dist/safety/expected-peaks.d.ts +24 -0
  36. package/dist/safety/expected-peaks.js +24 -0
  37. package/dist/safety/gcode-thermal.d.ts +64 -0
  38. package/dist/safety/gcode-thermal.js +382 -0
  39. package/dist/safety/limits.d.ts +31 -0
  40. package/dist/safety/limits.js +105 -0
  41. package/dist/safety/print-file.d.ts +25 -0
  42. package/dist/safety/print-file.js +517 -0
  43. package/dist/safety/printer-state.d.ts +51 -0
  44. package/dist/safety/printer-state.js +365 -0
  45. package/dist/slicer/bambu-cli.d.ts +110 -0
  46. package/dist/slicer/bambu-cli.js +547 -0
  47. package/dist/slicer/profile-flatten.d.ts +104 -0
  48. package/dist/slicer/profile-flatten.js +676 -0
  49. package/dist/slicer/run-slicer.d.ts +18 -0
  50. package/dist/slicer/run-slicer.js +102 -0
  51. package/dist/slicer/slicer-error.d.ts +27 -0
  52. package/dist/slicer/slicer-error.js +53 -0
  53. package/dist/slicer/templates.d.ts +126 -0
  54. package/dist/slicer/templates.js +255 -0
  55. package/dist/stl/stl-manipulator.d.ts +25 -3
  56. package/dist/stl/stl-manipulator.js +214 -232
  57. package/package.json +16 -9
  58. package/server.json +29 -0
  59. package/src/3mf_parser.ts +8 -8
  60. package/src/blender-mcp-bridge.ts +200 -19
  61. package/src/fulu-orca.ts +36 -6
  62. package/src/index.ts +686 -137
  63. package/src/printers/bambu-node-guard.ts +44 -0
  64. package/src/printers/bambu.ts +458 -164
  65. package/src/printers/creality.ts +40 -9
  66. package/src/printers/duet.ts +64 -53
  67. package/src/printers/generic-base.ts +286 -0
  68. package/src/printers/klipper.ts +83 -28
  69. package/src/printers/octoprint.ts +74 -21
  70. package/src/printers/printer-factory.ts +21 -11
  71. package/src/printers/prusa.ts +53 -10
  72. package/src/printers/repetier.ts +55 -17
  73. package/src/safety/archive.ts +181 -0
  74. package/src/safety/artifact.ts +58 -0
  75. package/src/safety/confirmation.ts +56 -0
  76. package/src/safety/expected-peaks.ts +47 -0
  77. package/src/safety/gcode-thermal.ts +404 -0
  78. package/src/safety/limits.ts +98 -0
  79. package/src/safety/print-file.ts +354 -0
  80. package/src/safety/printer-state.ts +339 -0
  81. package/src/slicer/bambu-cli.ts +633 -0
  82. package/src/slicer/profile-flatten.ts +830 -0
  83. package/src/slicer/run-slicer.ts +126 -0
  84. package/src/slicer/slicer-error.ts +72 -0
  85. package/src/slicer/templates.ts +288 -0
  86. package/src/stl/stl-manipulator.ts +247 -275
@@ -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.log(`Found Bambu config file: ${configFile.name}`);
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.log('Bambu config parsed successfully as JSON.');
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.log('Bambu config parsed successfully as INI (fallback).');
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.log('No Bambu-specific config file found in Metadata directory.');
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.log('3dmodel.model parsed successfully.');
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.log(`Parsing 3MF file: ${filePath}`);
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.log('Files in 3MF:', fileList);
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.log('3MF parsing completed.');
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-mcp\"]). Start the matching addon in 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
- if (!Number.isFinite(chunk.readFloatLE(face + coordinate)))
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 requestedOutput = args.output_path === undefined
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. Inspect blender_mcp_status before calling the tool.");
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 }) => ({ configured: true, connected: true, transport: "stdio", server: client.getServerVersion(), capabilities: client.getServerCapabilities(), 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 printer, account, or cloud state. ` +
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
- printMethodsRequireBambuModel: Array.from(FULU_PRINT_RPC_METHODS),
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
  ],