forma-dsl-mcp 0.1.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js CHANGED
@@ -4,20 +4,35 @@
4
4
  * Tools are the part a model calls; resources are the part a client can attach to a
5
5
  * conversation by hand, and carry the same knowledge in a form a person can browse. Both
6
6
  * read from the same modules, so there is one description of the language here, not two.
7
+ *
8
+ * The knowledge is bundled rather than left to the error messages. A refusal names the
9
+ * alternatives, which is enough to correct a misspelling and not enough to learn a language:
10
+ * it teaches the name of a block and nothing about what that block means, what it takes, or
11
+ * which of its attributes interact. A caller with no manual can find `cone` from `frustum`
12
+ * that way; it cannot find `revolve` at all.
7
13
  */
8
14
  /** @import { Workspace } from './workspace.js' */
9
15
 
16
+ import { createRequire } from 'node:module';
17
+
10
18
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
11
- import { z } from 'zod';
12
19
 
13
20
  import { CONSTRUCTS, LANGUAGE_CONSTANTS, LANGUAGE_FUNCTIONS } from './catalogue.js';
14
21
  import { EXAMPLES, EXAMPLES_BY_NAME } from './examples.js';
15
22
  import { PAGES, readPage } from './reference.js';
23
+ import { clientWorkspace } from './roots.js';
16
24
  import { guide } from './guide.js';
17
25
  import { registerTools } from './tools.js';
18
26
 
19
- /** The package's own version, reported in the handshake. */
20
- export const VERSION = '0.1.0';
27
+ /**
28
+ * The package's own version, reported in the handshake.
29
+ *
30
+ * Read from the manifest rather than written out here, because the two drifted the moment
31
+ * they were allowed to: a client was told 0.1.0 by a server published as 0.1.5, and nothing
32
+ * in the release could notice. `files` ships `src/` and npm always ships the manifest, so
33
+ * this resolves in the published package as well as in the tree.
34
+ */
35
+ export const VERSION = createRequire(import.meta.url)('../package.json').version;
21
36
 
22
37
  /**
23
38
  * What the server tells a client it is for, offered as instructions during initialisation.
@@ -25,34 +40,42 @@ export const VERSION = '0.1.0';
25
40
  * Clients put this in front of the model before it has called anything, so it is the one
26
41
  * place to say the thing that changes behaviour most: check before you claim it works.
27
42
  */
28
- const INSTRUCTIONS = `This server knows forma, a declarative DSL for 3D solids, and can write
29
- and solve .forma documents.
43
+ const INSTRUCTIONS = `This server knows forma, a declarative DSL for 3D solids, builds it with
44
+ the real geometry kernel, and writes the documents it builds.
30
45
 
31
46
  Start with \`forma_guide\` — it is the language in one page. Use \`forma_blocks\` for the
32
47
  attributes of the blocks you are about to write, \`forma_examples\` for a working document of
33
48
  a similar shape, and \`forma_reference\` when you need the manual.
34
49
 
35
- Never hand a .forma document to anyone without running \`forma_check\` on it first. The check
36
- builds the geometry with the real kernel and reports the bounding box, the volume and the
37
- triangle count of every part, which is the only way to know that the model is the size that
38
- was asked for and has not silently solved to nothing. Write files with \`forma_write\`, which
39
- runs the same check and refuses a document that does not render.`;
50
+ Never hand a .forma document to anyone without writing it through \`forma_write\`. It compiles
51
+ the document, solves it and reports the bounding box, the volume and the triangle count of
52
+ every part, which is the only way to know that the model is the size that was asked for and
53
+ has not silently solved to nothing. A document that does not build is refused rather than
54
+ written. Read one back with \`forma_read\`, and export a solid with \`forma_export_stl\`.
55
+
56
+ Where those files go is the client's decision, not the server's: the tools read and write
57
+ inside the directories the client declares as MCP roots, and a path is taken relative to the
58
+ first of them. Every write reports the directory it landed in.`;
40
59
 
41
60
  /**
42
- * Builds the server, with every tool, resource and prompt registered.
61
+ * Builds the server, with every tool and resource registered.
43
62
  *
44
- * @param {Workspace} workspace The patch of filesystem the file tools may touch.
63
+ * The workspace is optional, and left out is the normal case: the server then takes its
64
+ * directories from whatever the connected client declares as its roots, which is the point —
65
+ * the client knows where the user's files belong and the server does not.
66
+ *
67
+ * @param {Workspace} [workspace] The patch of filesystem the tools may touch. Omitted, the
68
+ * client's roots are used.
45
69
  * @returns {McpServer} The server, not yet connected to a transport.
46
70
  */
47
71
  export function createServer(workspace) {
48
72
  const server = new McpServer(
49
73
  { name: 'forma-dsl', version: VERSION },
50
- { capabilities: { tools: {}, resources: {}, prompts: {} }, instructions: INSTRUCTIONS },
74
+ { capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS },
51
75
  );
52
76
 
53
- registerTools(server, workspace);
77
+ registerTools(server, workspace ?? clientWorkspace(server.server));
54
78
  registerResources(server);
55
- registerPrompts(server);
56
79
 
57
80
  return server;
58
81
  }
@@ -136,84 +159,3 @@ function registerResources(server) {
136
159
  );
137
160
  }
138
161
 
139
- /**
140
- * Registers the prompts, which are the workflows worth having a name for.
141
- *
142
- * @param {McpServer} server The server.
143
- * @returns {void}
144
- */
145
- function registerPrompts(server) {
146
- server.registerPrompt('model_a_part', {
147
- title: 'Model a part in forma',
148
- description: 'Walks from a description of a physical object to a checked .forma document.',
149
- argsSchema: {
150
- part: z.string().describe('What to model, e.g. "a wall bracket for a 35 mm pipe".'),
151
- constraints: z.string().optional()
152
- .describe('Dimensions, tolerances, print constraints — anything the result has to satisfy.'),
153
- path: z.string().optional().describe('Where to write the finished document.'),
154
- },
155
- }, ({ part, constraints, path }) => ({
156
- messages: [{
157
- role: 'user',
158
- content: {
159
- type: 'text',
160
- text: [
161
- `Model this in forma: ${part}.`,
162
- constraints ? `\nIt has to satisfy: ${constraints}` : '',
163
- '',
164
- 'Work in this order:',
165
- '1. Call `forma_guide` if you have not already read it this session.',
166
- '2. Call `forma_examples` and read the one closest to what this is.',
167
- '3. Decide what is a `param` (anything a person would reasonably change, with a '
168
- + 'default, a min and a max) and what is a `local` (anything derived from those).',
169
- '4. Write the document. Prefer a 2D profile given thickness by `extrude` or '
170
- + '`revolve` over composing solids. Name any repeated feature — including the '
171
- + 'negative of one — as a `component`. Give each physical piece its own `part` with '
172
- + 'a colour.',
173
- '5. Call `forma_check` with `solve: true`. Read the bounding box and the volume back '
174
- + 'and confirm they are what was asked for. A part with zero volume means the model '
175
- + 'silently produced nothing — fix it, do not report it as done.',
176
- '6. Iterate until it renders and measures correctly.',
177
- path
178
- ? `7. Write it to \`${path}\` with \`forma_write\`.`
179
- : '7. Offer to write it with `forma_write` once it checks out.',
180
- '',
181
- 'Report the finished document along with its measured size, and say which params '
182
- + 'drive which dimension.',
183
- ].filter(Boolean).join('\n'),
184
- },
185
- }],
186
- }));
187
-
188
- server.registerPrompt('review_a_document', {
189
- title: 'Review a .forma document',
190
- description: 'Checks an existing document and reports what is wrong or fragile about it.',
191
- argsSchema: { path: z.string().describe('The document to review.') },
192
- }, ({ path }) => ({
193
- messages: [{
194
- role: 'user',
195
- content: {
196
- type: 'text',
197
- text: [
198
- `Review the forma document at \`${path}\`.`,
199
- '',
200
- 'Call `forma_read` on it, then look for:',
201
- '- parts that solved to nothing, or a bounding box that is not what the document '
202
- + 'seems to intend;',
203
- '- a `part` nested inside a transform or a boolean, which is silently dropped;',
204
- '- a `difference` whose cutter ends exactly on a face, leaving a zero-thickness skin;',
205
- '- dimensions written as bare numbers where a `param` or a `local` would say what '
206
- + 'they mean;',
207
- '- a `param` whose `min`/`max` the model does not actually clamp, since those bounds '
208
- + 'are advisory and never enforced;',
209
- '- geometry that would not print: unsupported overhangs, walls thinner than a nozzle, '
210
- + 'a non-zero genus where a solid was meant.',
211
- '',
212
- 'Re-check with different param values at the ends of their declared ranges — a model '
213
- + 'that only works at its defaults is the common failure. Report what you found, and '
214
- + 'propose the smallest edit that fixes each one.',
215
- ].join('\n'),
216
- },
217
- }],
218
- }));
219
- }
package/src/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The tools the server offers, and how their results are worded.
2
+ * The three tools the server offers, and how their results are worded.
3
3
  *
4
4
  * Results are Markdown rather than JSON on purpose. Everything here is read by a language
5
5
  * model deciding what to write next, and a bounding box in a table is acted on where the
@@ -34,7 +34,7 @@ const say = (text, isError = false) => ({
34
34
  *
35
35
  * A thrown error becomes a protocol-level failure, which most clients surface as "the tool
36
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.
37
+ * outside the roots, a part that does not exist — belongs in the result instead.
38
38
  *
39
39
  * @param {() => Promise<object>} handler What to run.
40
40
  * @returns {Promise<object>} Its result, or the failure as text.
@@ -189,45 +189,34 @@ export function formatReport(report) {
189
189
  lines.push(...parameterLines(report.parameters));
190
190
 
191
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
192
 
194
193
  return lines.join('\n');
195
194
  }
196
195
 
197
196
  /**
198
- * Resolves the `source`-or-`path` pair every document-taking tool accepts.
197
+ * Resolves the `source`-or-`path` pair the export tool accepts.
199
198
  *
200
199
  * @param {Workspace} workspace Where a path is resolved.
201
200
  * @param {{ source?: string, path?: string }} args What the caller gave.
202
- * @returns {Promise<{ source: string, from: string }>} The document, and where it came from.
201
+ * @returns {Promise<string>} The document.
203
202
  * @throws {Error} If neither or both were given.
204
203
  */
205
204
  async function documentFrom(workspace, { source, path }) {
206
205
  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');
206
+ if (source) return source;
207
+ if (path) return workspace.read(path);
208
+ throw new Error('give either "source" — the document itself — or "path" to one already written');
210
209
  }
211
210
 
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
211
  /**
222
- * Registers every tool on a server.
212
+ * The language in one page, or one section of it.
213
+ *
214
+ * Registered first because it is what the server's instructions tell a model to read first.
223
215
  *
224
216
  * @param {McpServer} server The server.
225
- * @param {Workspace} workspace The patch of filesystem the file tools may touch.
226
217
  * @returns {void}
227
218
  */
228
- export function registerTools(server, workspace) {
229
- // --- knowing the language ---------------------------------------------------------------
230
-
219
+ function registerGuide(server) {
231
220
  server.registerTool('forma_guide', {
232
221
  title: 'The forma language in brief',
233
222
  description:
@@ -241,7 +230,18 @@ export function registerTools(server, workspace) {
241
230
  },
242
231
  annotations: { readOnlyHint: true, openWorldHint: false },
243
232
  }, async ({ section }) => attempt(async () => say(guide(section))));
233
+ }
244
234
 
235
+ /**
236
+ * What every block takes, read from the library's own registry.
237
+ *
238
+ * Names are answered in full rather than as a list, because a caller that has to guess at
239
+ * an attribute spends a build finding out it guessed wrong.
240
+ *
241
+ * @param {McpServer} server The server.
242
+ * @returns {void}
243
+ */
244
+ function registerBlocks(server) {
245
245
  server.registerTool('forma_blocks', {
246
246
  title: 'Block and declaration reference',
247
247
  description:
@@ -294,7 +294,18 @@ export function registerTools(server, workspace) {
294
294
  lines.push('_Call again with `names` for the attribute tables._');
295
295
  return say(lines.join('\n'));
296
296
  }));
297
+ }
297
298
 
299
+ /**
300
+ * Every function an expression may call, and the two named constants.
301
+ *
302
+ * There are no user-defined functions in forma, so this answer is complete rather than a
303
+ * sample — which is worth saying, because it is what stops a caller inventing one.
304
+ *
305
+ * @param {McpServer} server The server.
306
+ * @returns {void}
307
+ */
308
+ function registerFunctions(server) {
298
309
  server.registerTool('forma_functions', {
299
310
  title: 'Builtin functions and constants',
300
311
  description:
@@ -316,7 +327,18 @@ export function registerTools(server, workspace) {
316
327
  '',
317
328
  table(['Name', 'Value'], LANGUAGE_CONSTANTS.map((c) => [`\`${c.name}\``, c.value])),
318
329
  ].join('\n'))));
330
+ }
319
331
 
332
+ /**
333
+ * The bundled manual: search it, read a page, or list what there is.
334
+ *
335
+ * The manual ships with the package rather than being fetched, so the answers stay true to
336
+ * the version installed and work with no network at all.
337
+ *
338
+ * @param {McpServer} server The server.
339
+ * @returns {void}
340
+ */
341
+ function registerReference(server) {
320
342
  server.registerTool('forma_reference', {
321
343
  title: 'The reference manual',
322
344
  description:
@@ -357,7 +379,18 @@ export function registerTools(server, workspace) {
357
379
  table(['Page', 'Title', 'About'], PAGES.map((each) => [`\`${each.path}\``, each.title, each.about])),
358
380
  ].join('\n'));
359
381
  }));
382
+ }
360
383
 
384
+ /**
385
+ * Complete documents that render, to read before writing something new.
386
+ *
387
+ * Every one is rendered by this package's tests at both ends of every parameter's range, so
388
+ * an example cannot teach a mistake without the build failing.
389
+ *
390
+ * @param {McpServer} server The server.
391
+ * @returns {void}
392
+ */
393
+ function registerExamples(server) {
361
394
  server.registerTool('forma_examples', {
362
395
  title: 'Worked documents',
363
396
  description:
@@ -393,48 +426,38 @@ export function registerTools(server, workspace) {
393
426
  ].join('\n'));
394
427
  }));
395
428
 
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
- }));
429
+ // --- writing and building ----------------------------------------------------------------
430
+ }
425
431
 
432
+ /**
433
+ * Build a document, and write it only if it built.
434
+ *
435
+ * The measurement is the point, not the file: a caller that is told the bounding box, the
436
+ * volume and the triangle count knows whether the model is the size that was asked for and
437
+ * has not silently solved to nothing. A refusal leaves nothing on disk.
438
+ *
439
+ * @param {McpServer} server The server.
440
+ * @param {Workspace} workspace The patch of filesystem the tool may touch.
441
+ * @returns {void}
442
+ */
443
+ function registerWrite(server, workspace) {
426
444
  server.registerTool('forma_write', {
427
- title: 'Write a .forma file',
445
+ title: 'Check, then write a .forma file',
428
446
  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.',
447
+ 'Builds a .forma document with the real geometry kernel and, if it renders, writes it. '
448
+ + 'The result is the measurement: a table of every part with its bounding box, volume, '
449
+ + 'triangle count and genus, or the error with the line, the column and an excerpt with '
450
+ + 'a caret on it. This is how you find out whether what you wrote is the size you meant, '
451
+ + 'is watertight, and is not silently empty. A document that does not render is refused '
452
+ + 'and nothing is written — pass `force: true` to write it anyway. Refuses to replace an '
453
+ + 'existing file unless `overwrite` is set.',
433
454
  inputSchema: {
434
- path: z.string().describe('Where to write, relative to the workspace root. Must end in .forma.'),
455
+ path: z.string()
456
+ .describe('Where to write, relative to the first directory the client declared as a root. Must end in .forma.'),
435
457
  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.'),
458
+ model: z.string().optional().describe('Which model to build. Defaults to the first one declared.'),
459
+ params: z.record(z.string(), z.any()).optional()
460
+ .describe('Values for the document\'s params, by name. Anything not given uses its default.'),
438
461
  overwrite: z.boolean().optional().describe('Replace the file if it already exists. Default false.'),
439
462
  force: z.boolean().optional().describe('Write even if the document does not render. Default false.'),
440
463
  },
@@ -452,19 +475,35 @@ export function registerTools(server, workspace) {
452
475
 
453
476
  const written = await workspace.write(path, source, { overwrite, extension: '.forma' });
454
477
  return say([
455
- `Wrote \`${written.relative}\` — ${written.bytes} bytes${written.replaced ? ', replacing what was there' : ''}.`,
478
+ `Wrote \`${written.relative}\` under \`${written.root}\` — ${written.bytes} bytes`
479
+ + `${written.replaced ? ', replacing what was there' : ''}.`,
456
480
  '',
457
481
  formatReport(report),
458
482
  ].join('\n'));
459
483
  }));
484
+ }
460
485
 
486
+ /**
487
+ * Read a document back, with its current measurements.
488
+ *
489
+ * Source and measurements together, because editing something last touched in another
490
+ * session means needing both.
491
+ *
492
+ * @param {McpServer} server The server.
493
+ * @param {Workspace} workspace The patch of filesystem the tool may touch.
494
+ * @returns {void}
495
+ */
496
+ function registerRead(server, workspace) {
461
497
  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.',
498
+ title: 'Read a document back',
499
+ description:
500
+ 'Reads a .forma file the client has given access to and builds it, so you see the source '
501
+ + 'and its current measurements together. Use it before editing something you did not '
502
+ + 'write in this session.',
465
503
  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.'),
504
+ path: z.string().describe('The file, relative to the first directory the client declared as a root.'),
505
+ solve: z.boolean().optional()
506
+ .describe('Run the geometry kernel. Default true; false parses only, which is faster.'),
468
507
  },
469
508
  annotations: { readOnlyHint: true, openWorldHint: false },
470
509
  }, async ({ path, solve }) => attempt(async () => {
@@ -472,40 +511,41 @@ export function registerTools(server, workspace) {
472
511
  const report = await checkDocument(source, { solve: solve ?? true });
473
512
  return say(['```hcl', source.trimEnd(), '```', '', formatReport(report)].join('\n'));
474
513
  }));
514
+ }
475
515
 
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
-
516
+ /**
517
+ * Render a document and write one part, or every part unioned, as binary STL.
518
+ *
519
+ * Takes a document either as source or as a path already on disk, so a caller that has just
520
+ * written one does not have to hold it a second time.
521
+ *
522
+ * @param {McpServer} server The server.
523
+ * @param {Workspace} workspace The patch of filesystem the tool may touch.
524
+ * @returns {void}
525
+ */
526
+ function registerExportStl(server, workspace) {
493
527
  server.registerTool('forma_export_stl', {
494
- title: 'Export a model as binary STL',
528
+ title: 'Render and write a binary STL',
495
529
  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.',
530
+ 'Renders a document and writes one part — or every part unioned — as a binary STL. '
531
+ + 'Lengths are unitless in forma and STL has no unit either; every consumer treats them '
532
+ + 'as millimetres.',
499
533
  inputSchema: {
500
- ...DOCUMENT_ARGS,
501
- out: z.string().describe('Where to write, relative to the workspace root. Must end in .stl.'),
534
+ source: z.string().optional().describe('The .forma document itself.'),
535
+ path: z.string().optional()
536
+ .describe('A .forma file the client has given access to, instead of `source`.'),
537
+ model: z.string().optional().describe('Which model to build. Defaults to the first one declared.'),
538
+ params: z.record(z.string(), z.any()).optional()
539
+ .describe('Values for the document\'s params, by name. Anything not given uses its default.'),
540
+ out: z.string()
541
+ .describe('Where to write, relative to the first directory the client declared as a root. Must end in .stl.'),
502
542
  part: z.string().optional()
503
543
  .describe('One part by name. Omitted, a single-part model exports that part and a multi-part model exports them unioned.'),
504
544
  overwrite: z.boolean().optional().describe('Replace the file if it already exists. Default false.'),
505
545
  },
506
546
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
507
547
  }, async (args) => attempt(async () => {
508
- const { source } = await documentFrom(workspace, args);
548
+ const source = await documentFrom(workspace, args);
509
549
  const result = await exportStl(source, {
510
550
  model: args.model ?? null,
511
551
  params: args.params ?? {},
@@ -516,8 +556,32 @@ export function registerTools(server, workspace) {
516
556
  extension: '.stl',
517
557
  });
518
558
  return say(
519
- `Wrote \`${written.relative}\` — ${written.bytes} bytes, ${result.triangles} triangles, `
520
- + `from model \`${result.model}\`, part \`${result.part}\`.`,
559
+ `Wrote \`${written.relative}\` under \`${written.root}\` — ${written.bytes} bytes, `
560
+ + `${result.triangles} triangles, from model \`${result.model}\`, part \`${result.part}\`.`,
521
561
  );
522
562
  }));
523
563
  }
564
+
565
+ /**
566
+ * Registers every tool on a server.
567
+ *
568
+ * One function per tool, so this reads as the list of what the server offers and each tool's
569
+ * schema, description and handler stay together where they are edited.
570
+ *
571
+ * @param {McpServer} server The server.
572
+ * @param {Workspace} workspace The patch of filesystem the file tools may touch.
573
+ * @returns {void}
574
+ */
575
+ export function registerTools(server, workspace) {
576
+ // Knowing the language.
577
+ registerGuide(server);
578
+ registerBlocks(server);
579
+ registerFunctions(server);
580
+ registerReference(server);
581
+ registerExamples(server);
582
+
583
+ // Writing and building.
584
+ registerWrite(server, workspace);
585
+ registerRead(server, workspace);
586
+ registerExportStl(server, workspace);
587
+ }