forma-dsl-mcp 0.1.4

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +52 -0
  4. package/src/bin.js +30 -0
  5. package/src/catalogue.js +703 -0
  6. package/src/document.js +285 -0
  7. package/src/examples.js +212 -0
  8. package/src/guide.js +313 -0
  9. package/src/reference/README.md +153 -0
  10. package/src/reference/api/describe-parameters.md +167 -0
  11. package/src/reference/api/evaluation-context.md +221 -0
  12. package/src/reference/api/export.md +185 -0
  13. package/src/reference/api/geometry-node.md +215 -0
  14. package/src/reference/api/kernel.md +182 -0
  15. package/src/reference/api/low-level.md +239 -0
  16. package/src/reference/api/render.md +217 -0
  17. package/src/reference/api/values.md +223 -0
  18. package/src/reference/errors.md +253 -0
  19. package/src/reference/language/control-flow.md +160 -0
  20. package/src/reference/language/expressions.md +206 -0
  21. package/src/reference/language/overview.md +156 -0
  22. package/src/reference/language/scope.md +184 -0
  23. package/src/reference/language/syntax.md +210 -0
  24. package/src/reference/reference/align.md +189 -0
  25. package/src/reference/reference/booleans.md +231 -0
  26. package/src/reference/reference/component.md +248 -0
  27. package/src/reference/reference/conversions.md +269 -0
  28. package/src/reference/reference/functions.md +314 -0
  29. package/src/reference/reference/local.md +197 -0
  30. package/src/reference/reference/model.md +213 -0
  31. package/src/reference/reference/param.md +230 -0
  32. package/src/reference/reference/part.md +229 -0
  33. package/src/reference/reference/refinement.md +284 -0
  34. package/src/reference/reference/shapes-2d.md +309 -0
  35. package/src/reference/reference/shapes-3d.md +279 -0
  36. package/src/reference/reference/transforms.md +246 -0
  37. package/src/reference.js +160 -0
  38. package/src/server.js +219 -0
  39. package/src/tools.js +523 -0
  40. package/src/workspace.js +204 -0
package/src/tools.js ADDED
@@ -0,0 +1,523 @@
1
+ /**
2
+ * The tools the server offers, and how their results are worded.
3
+ *
4
+ * Results are Markdown rather than JSON on purpose. Everything here is read by a language
5
+ * model deciding what to write next, and a bounding box in a table is acted on where the
6
+ * same numbers inside a JSON blob are skimmed. The one exception is a parameter list, which
7
+ * is data a client may want to build controls from, and is given as both.
8
+ */
9
+ /** @import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' */
10
+ /** @import { Workspace } from './workspace.js' */
11
+
12
+ import { z } from 'zod';
13
+
14
+ import { CONSTRUCTS, LANGUAGE_CONSTANTS, LANGUAGE_FUNCTIONS, formatConstruct } from './catalogue.js';
15
+ import { EXAMPLES, EXAMPLES_BY_NAME } from './examples.js';
16
+ import { GUIDE_SECTIONS, guide } from './guide.js';
17
+ import { PAGES, readPage, searchReference } from './reference.js';
18
+ import { checkDocument, exportStl } from './document.js';
19
+
20
+ /**
21
+ * Wraps text as a tool result.
22
+ *
23
+ * @param {string} text What to say.
24
+ * @param {boolean} [isError] Whether the call failed.
25
+ * @returns {{ content: Array<{ type: 'text', text: string }>, isError?: boolean }} The result.
26
+ */
27
+ const say = (text, isError = false) => ({
28
+ content: [{ type: /** @type {'text'} */ ('text'), text }],
29
+ ...(isError ? { isError: true } : {}),
30
+ });
31
+
32
+ /**
33
+ * Runs a handler, turning anything it throws into a result the caller can read.
34
+ *
35
+ * A thrown error becomes a protocol-level failure, which most clients surface as "the tool
36
+ * broke" rather than as something the model can correct. A refusal it can read — a path
37
+ * outside the root, a part that does not exist — belongs in the result instead.
38
+ *
39
+ * @param {() => Promise<object>} handler What to run.
40
+ * @returns {Promise<object>} Its result, or the failure as text.
41
+ */
42
+ async function attempt(handler) {
43
+ try {
44
+ return await handler();
45
+ } catch (error) {
46
+ return say(`Failed: ${/** @type {Error} */ (error)?.message ?? String(error)}`, true);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Renders a Markdown table.
52
+ *
53
+ * @param {string[]} headers The column headings.
54
+ * @param {Array<Array<string | number>>} rows The rows.
55
+ * @returns {string} The table, or a dash when there are no rows.
56
+ */
57
+ function table(headers, rows) {
58
+ if (!rows.length) return '_none_';
59
+ return [
60
+ `| ${headers.join(' | ')} |`,
61
+ `| ${headers.map(() => '---').join(' | ')} |`,
62
+ ...rows.map((row) => `| ${row.join(' | ')} |`),
63
+ ].join('\n');
64
+ }
65
+
66
+ /**
67
+ * The error, if there is one, with the excerpt that points at it.
68
+ *
69
+ * @param {object} report What {@link checkDocument} returned.
70
+ * @returns {string[]} The lines, or none when the document is fine.
71
+ */
72
+ function problemLines(report) {
73
+ /** @type {string[]} */
74
+ const lines = [];
75
+ if (report.error) {
76
+ const { stage, message, line, column, excerpt } = report.error;
77
+ lines.push(`**${stage}**: ${message}`, '');
78
+ if (line) lines.push(`At line ${line}, column ${column}:`, '', '```', excerpt, '```', '');
79
+ }
80
+ if (report.parameterError) lines.push(`**parameters**: ${report.parameterError.message}`, '');
81
+ return lines;
82
+ }
83
+
84
+ /**
85
+ * The declared params, as the table a front end would build controls from.
86
+ *
87
+ * @param {Array<object>} parameters The descriptors.
88
+ * @returns {string[]} The lines, or none when the document declares no params.
89
+ */
90
+ function parameterLines(parameters) {
91
+ if (!parameters?.length) return [];
92
+ return ['## Parameters', '', table(
93
+ ['Name', 'Type', 'Default', 'Required', 'Range'],
94
+ parameters.map((parameter) => {
95
+ const bounded = parameter.min !== undefined || parameter.max !== undefined;
96
+ return [
97
+ `\`${parameter.name}\``,
98
+ parameter.type,
99
+ parameter.default === undefined ? '—' : JSON.stringify(parameter.default),
100
+ parameter.required ? 'yes' : 'no',
101
+ bounded ? `${parameter.min ?? '−∞'} … ${parameter.max ?? '∞'}` : '—',
102
+ ];
103
+ }),
104
+ ), ''];
105
+ }
106
+
107
+ /**
108
+ * The solved scene, measured.
109
+ *
110
+ * A part that solved to nothing is called out rather than left in the table, because that is
111
+ * the failure a caller is most likely to report as a success.
112
+ *
113
+ * @param {object} scene What the render produced.
114
+ * @param {object} [stats] The render's own counters.
115
+ * @returns {string[]} The lines.
116
+ */
117
+ function sceneLines(scene, stats) {
118
+ const { model, parts, boundingBox, triangles } = scene;
119
+ const lines = [`## Scene \`${model}\``, '', table(
120
+ ['Part', 'Colour', 'Opacity', 'Size (X × Y × Z)', 'Volume', 'Triangles', 'Genus'],
121
+ parts.map((part) => [
122
+ `\`${part.name}\``,
123
+ part.color,
124
+ part.opacity,
125
+ part.boundingBox.size.join(' × '),
126
+ part.volume,
127
+ part.triangles,
128
+ part.genus,
129
+ ]),
130
+ ), ''];
131
+
132
+ const min = boundingBox.min.join(', ');
133
+ const max = boundingBox.max.join(', ');
134
+ lines.push(
135
+ `**Overall bounding box**: ${boundingBox.size.join(' × ')} (min ${min}, max ${max})`,
136
+ `**Triangles**: ${triangles}`,
137
+ '',
138
+ );
139
+
140
+ const empty = parts.filter((part) => part.empty || part.volume === 0);
141
+ if (empty.length) {
142
+ const named = empty.map((part) => `\`${part.name}\``).join(', ');
143
+ lines.push(
144
+ `> ⚠️ ${named} solved to nothing. A zero-height extrude, a difference that removed `
145
+ + 'everything, or a loop that ran zero times will do this silently.',
146
+ '',
147
+ );
148
+ }
149
+
150
+ if (stats) {
151
+ lines.push(
152
+ `_${stats.nodes} nodes, ${stats.evaluated} evaluated, ${stats.cacheHits} cache hits, `
153
+ + `${stats.milliseconds} ms._`,
154
+ );
155
+ }
156
+ return lines;
157
+ }
158
+
159
+ /**
160
+ * How a block's children are described in the listing.
161
+ *
162
+ * @param {object} entry The catalogue entry.
163
+ * @returns {string} What it takes.
164
+ */
165
+ function takesOf(entry) {
166
+ if (entry.leaf) return 'nothing';
167
+ if (entry.takes === 'same') return '2D or 3D';
168
+ return entry.takes ? `${entry.takes}D` : '—';
169
+ }
170
+
171
+ /**
172
+ * Turns a check report into the page a caller reads.
173
+ *
174
+ * @param {object} report What {@link checkDocument} returned.
175
+ * @returns {string} Markdown.
176
+ */
177
+ export function formatReport(report) {
178
+ const lines = [report.ok ? '# Document is valid' : '# Document has a problem', ''];
179
+ lines.push(...problemLines(report));
180
+
181
+ if (report.models) {
182
+ lines.push(
183
+ `**Models**: ${report.models.length ? report.models.join(', ') : '_none_'}`,
184
+ `**Components**: ${report.components.length ? report.components.join(', ') : '_none_'}`,
185
+ '',
186
+ );
187
+ }
188
+
189
+ lines.push(...parameterLines(report.parameters));
190
+
191
+ if (report.scene) lines.push(...sceneLines(report.scene, report.stats));
192
+ else if (report.ok) lines.push('_Parsed only — pass `solve: true` to build the geometry and measure it._');
193
+
194
+ return lines.join('\n');
195
+ }
196
+
197
+ /**
198
+ * Resolves the `source`-or-`path` pair every document-taking tool accepts.
199
+ *
200
+ * @param {Workspace} workspace Where a path is resolved.
201
+ * @param {{ source?: string, path?: string }} args What the caller gave.
202
+ * @returns {Promise<{ source: string, from: string }>} The document, and where it came from.
203
+ * @throws {Error} If neither or both were given.
204
+ */
205
+ async function documentFrom(workspace, { source, path }) {
206
+ if (source && path) throw new Error('give either "source" or "path", not both');
207
+ if (source) return { source, from: 'the supplied source' };
208
+ if (path) return { source: await workspace.read(path), from: `\`${path}\`` };
209
+ throw new Error('give either "source" — the document itself — or "path" to one in the workspace');
210
+ }
211
+
212
+ /** The arguments shared by every tool that takes a document and may render it. */
213
+ const DOCUMENT_ARGS = {
214
+ source: z.string().optional().describe('The .forma document itself.'),
215
+ path: z.string().optional().describe('A .forma file in the workspace, instead of `source`.'),
216
+ model: z.string().optional().describe('Which model to build. Defaults to the first one declared.'),
217
+ params: z.record(z.string(), z.any()).optional()
218
+ .describe('Values for the document\'s params, by name. Anything not given uses its default.'),
219
+ };
220
+
221
+ /**
222
+ * Registers every tool on a server.
223
+ *
224
+ * @param {McpServer} server The server.
225
+ * @param {Workspace} workspace The patch of filesystem the file tools may touch.
226
+ * @returns {void}
227
+ */
228
+ export function registerTools(server, workspace) {
229
+ // --- knowing the language ---------------------------------------------------------------
230
+
231
+ server.registerTool('forma_guide', {
232
+ title: 'The forma language in brief',
233
+ description:
234
+ 'The language compressed to what someone writing a document has to hold in their head: '
235
+ + 'the four top-level blocks, how nesting composes geometry, dimensionality, units, the '
236
+ + 'syntax rules that are not guessable, and the mistakes that cost the most time. Read '
237
+ + 'this before writing any forma. Call with no section for all of it.',
238
+ inputSchema: {
239
+ section: z.enum(/** @type {[string, ...string[]]} */ (GUIDE_SECTIONS)).optional()
240
+ .describe('One section, instead of the whole guide.'),
241
+ },
242
+ annotations: { readOnlyHint: true, openWorldHint: false },
243
+ }, async ({ section }) => attempt(async () => say(guide(section))));
244
+
245
+ server.registerTool('forma_blocks', {
246
+ title: 'Block and declaration reference',
247
+ description:
248
+ 'Every block the language has, with its attributes, defaults, dimensionality and the '
249
+ + 'things that surprise people about it. Call with no arguments for the full list of '
250
+ + 'names and one-line summaries; name the ones you are about to use to get their '
251
+ + 'attribute tables. Covers the declarations (param, local, component, model) and the '
252
+ + 'control-flow constructs too.',
253
+ inputSchema: {
254
+ names: z.array(z.string()).optional()
255
+ .describe('The blocks to describe in full, e.g. ["extrude", "difference"].'),
256
+ group: z.string().optional()
257
+ .describe('One group, e.g. "2D shapes", "Transforms", "Refinement", "Declarations".'),
258
+ },
259
+ annotations: { readOnlyHint: true, openWorldHint: false },
260
+ }, async ({ names, group }) => attempt(async () => {
261
+ const all = [...CONSTRUCTS.values()];
262
+
263
+ if (names?.length) {
264
+ const unknown = names.filter((name) => !CONSTRUCTS.has(name));
265
+ if (unknown.length) {
266
+ const quoted = unknown.map((name) => '"' + name + '"').join(', ');
267
+ return say(
268
+ `No such block: ${quoted}. The names are ${all.map((entry) => entry.name).join(', ')}.`,
269
+ true,
270
+ );
271
+ }
272
+ return say(names.map((name) => formatConstruct(CONSTRUCTS.get(name))).join('\n---\n\n'));
273
+ }
274
+
275
+ const wanted = group ? all.filter((entry) => entry.group === group) : all;
276
+ if (!wanted.length) {
277
+ const groups = [...new Set(all.map((entry) => entry.group))];
278
+ return say(`No group "${group}". The groups are ${groups.join(', ')}.`, true);
279
+ }
280
+ if (group) return say(wanted.map((entry) => formatConstruct(entry)).join('\n---\n\n'));
281
+
282
+ const lines = ['# Every construct in the language', ''];
283
+ for (const name of new Set(all.map((entry) => entry.group))) {
284
+ lines.push(`## ${name}`, '', table(
285
+ ['Block', 'Dim', 'Takes', 'Summary'],
286
+ all.filter((entry) => entry.group === name).map((entry) => [
287
+ `\`${entry.name}\``,
288
+ entry.dim ? `${entry.dim}D` : '—',
289
+ takesOf(entry),
290
+ entry.summary,
291
+ ]),
292
+ ), '');
293
+ }
294
+ lines.push('_Call again with `names` for the attribute tables._');
295
+ return say(lines.join('\n'));
296
+ }));
297
+
298
+ server.registerTool('forma_functions', {
299
+ title: 'Builtin functions and constants',
300
+ description:
301
+ 'Every function an expression may call, with its arity, and the two named constants. '
302
+ + 'There are no user-defined functions in forma, so this is the complete set.',
303
+ inputSchema: {},
304
+ annotations: { readOnlyHint: true, openWorldHint: false },
305
+ }, async () => attempt(async () => say([
306
+ '# Functions',
307
+ '',
308
+ 'Trigonometry takes and returns **degrees**. Functions take positional arguments only.',
309
+ '',
310
+ table(
311
+ ['Function', 'Arity', 'Description'],
312
+ LANGUAGE_FUNCTIONS.map((fn) => [`\`${fn.name}\``, fn.arity, fn.description]),
313
+ ),
314
+ '',
315
+ '# Constants',
316
+ '',
317
+ table(['Name', 'Value'], LANGUAGE_CONSTANTS.map((c) => [`\`${c.name}\``, c.value])),
318
+ ].join('\n'))));
319
+
320
+ server.registerTool('forma_reference', {
321
+ title: 'The reference manual',
322
+ description:
323
+ 'The language\'s full reference manual, bundled with this server. Pass `query` to '
324
+ + 'search it, or `page` to read one page whole. Reach for this when forma_blocks is not '
325
+ + 'enough — the manual has the worked examples, the caveats and the troubleshooting for '
326
+ + 'every error message the language raises. Call with no arguments to list the pages.',
327
+ inputSchema: {
328
+ query: z.string().optional().describe('Words to search for, e.g. "revolve profile axis".'),
329
+ page: z.string().optional().describe('One page to read, e.g. "reference/conversions.md".'),
330
+ },
331
+ annotations: { readOnlyHint: true, openWorldHint: false },
332
+ }, async ({ query, page }) => attempt(async () => {
333
+ if (page) {
334
+ const found = await readPage(page);
335
+ return say(`# ${found.title}\n_${found.path}_\n\n${found.markdown}`);
336
+ }
337
+ if (query) {
338
+ const hits = await searchReference(query);
339
+ if (!hits.length) return say(`Nothing in the manual matches "${query}".`);
340
+ const plural = hits.length > 1 ? 's' : '';
341
+ return say([
342
+ `# ${hits.length} page${plural} matching "${query}"`,
343
+ '',
344
+ ...hits.map((hit) => [
345
+ `## ${hit.title} — \`${hit.path}\``,
346
+ hit.heading ? `_under "${hit.heading}"_` : '',
347
+ '',
348
+ '> ' + hit.snippet.replaceAll('\n', '\n> '),
349
+ '',
350
+ ].join('\n')),
351
+ '_Read one whole with `page`._',
352
+ ].join('\n'));
353
+ }
354
+ return say([
355
+ '# The reference manual',
356
+ '',
357
+ table(['Page', 'Title', 'About'], PAGES.map((each) => [`\`${each.path}\``, each.title, each.about])),
358
+ ].join('\n'));
359
+ }));
360
+
361
+ server.registerTool('forma_examples', {
362
+ title: 'Worked documents',
363
+ description:
364
+ 'Complete .forma documents that render, each showing one way of building a real part. '
365
+ + 'Every one is rendered by this package\'s tests, so none of them teaches a mistake. '
366
+ + 'Read the nearest one before writing something new.',
367
+ inputSchema: {
368
+ name: z.string().optional().describe('One example to read in full.'),
369
+ },
370
+ annotations: { readOnlyHint: true, openWorldHint: false },
371
+ }, async ({ name }) => attempt(async () => {
372
+ if (name) {
373
+ const example = EXAMPLES_BY_NAME.get(name);
374
+ if (!example) {
375
+ return say(
376
+ `No example "${name}". They are ${EXAMPLES.map((each) => each.name).join(', ')}.`,
377
+ true,
378
+ );
379
+ }
380
+ return say([
381
+ `# ${example.name}`, '', example.summary, '',
382
+ `**Shows**: ${example.shows.join(', ')}`, '',
383
+ '```hcl', example.source.trimEnd(), '```',
384
+ ].join('\n'));
385
+ }
386
+ return say([
387
+ '# Worked examples', '',
388
+ table(
389
+ ['Name', 'Summary', 'Shows'],
390
+ EXAMPLES.map((each) => [`\`${each.name}\``, each.summary, each.shows.join(', ')]),
391
+ ),
392
+ '', '_Read one with `name`._',
393
+ ].join('\n'));
394
+ }));
395
+
396
+ // --- writing and checking ----------------------------------------------------------------
397
+
398
+ server.registerTool('forma_check', {
399
+ title: 'Check and measure a document',
400
+ description:
401
+ 'Parses a .forma document and, unless told not to, builds one of its models with the '
402
+ + 'real geometry kernel. Reports any error with the line, the column and an excerpt with '
403
+ + 'a caret on it, and reports a successful build as a table of parts with their bounding '
404
+ + 'boxes, volumes, triangle counts and genus. This is how you find out whether what you '
405
+ + 'wrote is the size you meant, is watertight, and is not silently empty. Use it after '
406
+ + 'every edit.',
407
+ inputSchema: {
408
+ ...DOCUMENT_ARGS,
409
+ solve: z.boolean().optional()
410
+ .describe('Run the geometry kernel. Default true; false parses only, which is faster while drafting.'),
411
+ },
412
+ annotations: { readOnlyHint: true, openWorldHint: false },
413
+ }, async (args) => attempt(async () => {
414
+ const { source, from } = await documentFrom(workspace, args);
415
+ const report = await checkDocument(source, {
416
+ model: args.model ?? null,
417
+ params: args.params ?? {},
418
+ solve: args.solve ?? true,
419
+ });
420
+ return {
421
+ content: [{ type: /** @type {'text'} */ ('text'), text: `${formatReport(report)}\n\n_Checked ${from}._` }],
422
+ ...(report.ok ? {} : { isError: false }),
423
+ };
424
+ }));
425
+
426
+ server.registerTool('forma_write', {
427
+ title: 'Write a .forma file',
428
+ description:
429
+ 'Checks a document and writes it into the workspace. The check runs first and a document '
430
+ + 'that does not render is refused, so a file on disk is one that works — pass '
431
+ + '`force: true` to write it anyway. Refuses to replace an existing file unless '
432
+ + '`overwrite` is set.',
433
+ inputSchema: {
434
+ path: z.string().describe('Where to write, relative to the workspace root. Must end in .forma.'),
435
+ source: z.string().describe('The document.'),
436
+ model: z.string().optional().describe('Which model to check. Defaults to the first one.'),
437
+ params: z.record(z.string(), z.any()).optional().describe('Param values to check with.'),
438
+ overwrite: z.boolean().optional().describe('Replace the file if it already exists. Default false.'),
439
+ force: z.boolean().optional().describe('Write even if the document does not render. Default false.'),
440
+ },
441
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
442
+ }, async ({ path, source, model, params, overwrite, force }) => attempt(async () => {
443
+ const report = await checkDocument(source, { model: model ?? null, params: params ?? {} });
444
+
445
+ if (!report.ok && !force) {
446
+ return say(
447
+ `${formatReport(report)}\n\n**Nothing was written.** Fix it and call again, `
448
+ + 'or pass `force: true` to write it as it stands.',
449
+ true,
450
+ );
451
+ }
452
+
453
+ const written = await workspace.write(path, source, { overwrite, extension: '.forma' });
454
+ return say([
455
+ `Wrote \`${written.relative}\` — ${written.bytes} bytes${written.replaced ? ', replacing what was there' : ''}.`,
456
+ '',
457
+ formatReport(report),
458
+ ].join('\n'));
459
+ }));
460
+
461
+ server.registerTool('forma_read', {
462
+ title: 'Read a .forma file',
463
+ description: 'Reads a document from the workspace and checks it, so you see the source and '
464
+ + 'its current state together.',
465
+ inputSchema: {
466
+ path: z.string().describe('The file, relative to the workspace root.'),
467
+ solve: z.boolean().optional().describe('Run the geometry kernel. Default true.'),
468
+ },
469
+ annotations: { readOnlyHint: true, openWorldHint: false },
470
+ }, async ({ path, solve }) => attempt(async () => {
471
+ const source = await workspace.read(path);
472
+ const report = await checkDocument(source, { solve: solve ?? true });
473
+ return say(['```hcl', source.trimEnd(), '```', '', formatReport(report)].join('\n'));
474
+ }));
475
+
476
+ server.registerTool('forma_list', {
477
+ title: 'List the .forma files in the workspace',
478
+ description: 'Every .forma document under the workspace root, so you can find what is '
479
+ + 'already here instead of guessing at names.',
480
+ inputSchema: {},
481
+ annotations: { readOnlyHint: true, openWorldHint: false },
482
+ }, async () => attempt(async () => {
483
+ const files = await workspace.list('.forma');
484
+ if (!files.length) return say(`No .forma files under ${workspace.root}.`);
485
+ const plural = files.length > 1 ? 's' : '';
486
+ return say([
487
+ `# ${files.length} document${plural} under ${workspace.root}`,
488
+ '',
489
+ ...files.map((file) => `- \`${file}\``),
490
+ ].join('\n'));
491
+ }));
492
+
493
+ server.registerTool('forma_export_stl', {
494
+ title: 'Export a model as binary STL',
495
+ description:
496
+ 'Renders a document and writes one part — or every part unioned — as a binary STL into '
497
+ + 'the workspace. Lengths are unitless in forma and STL has no unit either; every '
498
+ + 'consumer treats them as millimetres.',
499
+ inputSchema: {
500
+ ...DOCUMENT_ARGS,
501
+ out: z.string().describe('Where to write, relative to the workspace root. Must end in .stl.'),
502
+ part: z.string().optional()
503
+ .describe('One part by name. Omitted, a single-part model exports that part and a multi-part model exports them unioned.'),
504
+ overwrite: z.boolean().optional().describe('Replace the file if it already exists. Default false.'),
505
+ },
506
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
507
+ }, async (args) => attempt(async () => {
508
+ const { source } = await documentFrom(workspace, args);
509
+ const result = await exportStl(source, {
510
+ model: args.model ?? null,
511
+ params: args.params ?? {},
512
+ part: args.part ?? null,
513
+ });
514
+ const written = await workspace.write(args.out, result.bytes, {
515
+ overwrite: args.overwrite,
516
+ extension: '.stl',
517
+ });
518
+ return say(
519
+ `Wrote \`${written.relative}\` — ${written.bytes} bytes, ${result.triangles} triangles, `
520
+ + `from model \`${result.model}\`, part \`${result.part}\`.`,
521
+ );
522
+ }));
523
+ }