forma-dsl-mcp 0.2.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-dsl-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "A stdio MCP server that knows the forma DSL and writes .forma documents that render.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.31.0",
49
- "forma-dsl": "^0.2.2",
49
+ "forma-dsl": "^0.3.1",
50
50
  "zod": "^4.1.13"
51
51
  }
52
52
  }
package/src/document.js CHANGED
@@ -20,6 +20,19 @@ import {
20
20
  /** How many lines of source to show either side of an error. */
21
21
  const CONTEXT_LINES = 2;
22
22
 
23
+ /**
24
+ * How much geometry one document may build before the server refuses it.
25
+ *
26
+ * This server exists to build text a model wrote, which is the case the library leaves a
27
+ * budget off for. Nesting limits and `range` each bound one loop; only this bounds two of
28
+ * them nested, and that shape asks for more geometry than the process can finish — on a pipe,
29
+ * with no request timeout of its own, that is a server that never answers again.
30
+ *
31
+ * Set far above any honest document: the worked examples build tens of blocks. A model that
32
+ * reaches this has written a loop it did not mean to.
33
+ */
34
+ const MAX_NODES = 50_000;
35
+
23
36
  /**
24
37
  * The context reused across calls, so an edit-and-recheck loop pays only for the subtrees
25
38
  * that changed.
@@ -195,7 +208,7 @@ export async function checkDocument(source, options = {}) {
195
208
  /** @type {RenderResult} */
196
209
  let result;
197
210
  try {
198
- result = await render(source, { model, params, context: shared });
211
+ result = await render(source, { model, params, context: shared, maxNodes: MAX_NODES });
199
212
  } catch (error) {
200
213
  return { ok: false, stage: 'render', ...declared, error: diagnose('render', error, source) };
201
214
  }
@@ -255,7 +268,7 @@ export async function exportStl(source, options = {}) {
255
268
  const { model = null, params = {}, part = null } = options;
256
269
 
257
270
  shared ??= await EvaluationContext.create();
258
- const result = await render(source, { model, params, context: shared });
271
+ const result = await render(source, { model, params, context: shared, maxNodes: MAX_NODES });
259
272
 
260
273
  let solid;
261
274
  let name;
package/src/tools.js CHANGED
@@ -209,15 +209,14 @@ async function documentFrom(workspace, { source, path }) {
209
209
  }
210
210
 
211
211
  /**
212
- * 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.
213
215
  *
214
216
  * @param {McpServer} server The server.
215
- * @param {Workspace} workspace The patch of filesystem the tools may touch.
216
217
  * @returns {void}
217
218
  */
218
- export function registerTools(server, workspace) {
219
- // --- knowing the language ---------------------------------------------------------------
220
-
219
+ function registerGuide(server) {
221
220
  server.registerTool('forma_guide', {
222
221
  title: 'The forma language in brief',
223
222
  description:
@@ -231,7 +230,18 @@ export function registerTools(server, workspace) {
231
230
  },
232
231
  annotations: { readOnlyHint: true, openWorldHint: false },
233
232
  }, async ({ section }) => attempt(async () => say(guide(section))));
233
+ }
234
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) {
235
245
  server.registerTool('forma_blocks', {
236
246
  title: 'Block and declaration reference',
237
247
  description:
@@ -284,7 +294,18 @@ export function registerTools(server, workspace) {
284
294
  lines.push('_Call again with `names` for the attribute tables._');
285
295
  return say(lines.join('\n'));
286
296
  }));
297
+ }
287
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) {
288
309
  server.registerTool('forma_functions', {
289
310
  title: 'Builtin functions and constants',
290
311
  description:
@@ -306,7 +327,18 @@ export function registerTools(server, workspace) {
306
327
  '',
307
328
  table(['Name', 'Value'], LANGUAGE_CONSTANTS.map((c) => [`\`${c.name}\``, c.value])),
308
329
  ].join('\n'))));
330
+ }
309
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) {
310
342
  server.registerTool('forma_reference', {
311
343
  title: 'The reference manual',
312
344
  description:
@@ -347,7 +379,18 @@ export function registerTools(server, workspace) {
347
379
  table(['Page', 'Title', 'About'], PAGES.map((each) => [`\`${each.path}\``, each.title, each.about])),
348
380
  ].join('\n'));
349
381
  }));
382
+ }
350
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) {
351
394
  server.registerTool('forma_examples', {
352
395
  title: 'Worked documents',
353
396
  description:
@@ -384,7 +427,20 @@ export function registerTools(server, workspace) {
384
427
  }));
385
428
 
386
429
  // --- writing and building ----------------------------------------------------------------
430
+ }
387
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) {
388
444
  server.registerTool('forma_write', {
389
445
  title: 'Check, then write a .forma file',
390
446
  description:
@@ -425,7 +481,19 @@ export function registerTools(server, workspace) {
425
481
  formatReport(report),
426
482
  ].join('\n'));
427
483
  }));
484
+ }
428
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) {
429
497
  server.registerTool('forma_read', {
430
498
  title: 'Read a document back',
431
499
  description:
@@ -443,7 +511,19 @@ export function registerTools(server, workspace) {
443
511
  const report = await checkDocument(source, { solve: solve ?? true });
444
512
  return say(['```hcl', source.trimEnd(), '```', '', formatReport(report)].join('\n'));
445
513
  }));
514
+ }
446
515
 
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) {
447
527
  server.registerTool('forma_export_stl', {
448
528
  title: 'Render and write a binary STL',
449
529
  description:
@@ -481,3 +561,27 @@ export function registerTools(server, workspace) {
481
561
  );
482
562
  }));
483
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
+ }