forma-dsl-mcp 0.2.2 → 0.3.2

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/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![Release](https://github.com/kalpak44/forma-dsl/actions/workflows/release.yml/badge.svg)](https://github.com/kalpak44/forma-dsl/actions/workflows/release.yml)
4
4
  [![npm](https://img.shields.io/npm/v/forma-dsl-mcp.svg)](https://www.npmjs.com/package/forma-dsl-mcp)
5
+ [![M8ven Verified](https://m8ven.ai/api/agent-verify/badge?score=100)](https://m8ven.ai/verified/verify?id=d3529b4756004447)
5
6
 
6
7
  A [Model Context Protocol](https://modelcontextprotocol.io) server that knows
7
8
  [forma](https://www.npmjs.com/package/forma-dsl) — the declarative DSL for 3D solids — and
@@ -234,6 +235,21 @@ node packages/forma-dsl-mcp/src/bin.js # the server, on stdio
234
235
  Point a client at `node /absolute/path/to/packages/forma-dsl-mcp/src/bin.js` to use the
235
236
  working tree rather than the published package.
236
237
 
238
+ ## Privacy
239
+
240
+ This server makes **no network requests**. It imports no HTTP, socket or DNS module and calls
241
+ no `fetch`; the geometry kernel is WebAssembly loaded from disk and the manual is bundled in
242
+ this tarball, so it works with no network at all. There is no telemetry, no analytics, no
243
+ update check and no account.
244
+
245
+ It writes only inside the directories your client declares as MCP roots, never replaces a file
246
+ unless told to, and leaves nothing elsewhere — no cache, no config, no logs.
247
+
248
+ Your MCP client is a separate program with its own policy. Documents you ask a model to write
249
+ travel wherever that client sends them, which this server neither controls nor sees.
250
+
251
+ Full text: [PRIVACY.md](https://github.com/kalpak44/forma-dsl/blob/main/PRIVACY.md).
252
+
237
253
  ## License
238
254
 
239
255
  MIT — see [LICENSE](LICENSE).
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.2",
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.2",
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
@@ -17,6 +17,25 @@ import { GUIDE_SECTIONS, guide } from './guide.js';
17
17
  import { PAGES, readPage, searchReference } from './reference.js';
18
18
  import { checkDocument, exportStl } from './document.js';
19
19
 
20
+ /**
21
+ * What a tool that only reads what this package already carries is.
22
+ *
23
+ * All four hints are stated rather than just `readOnlyHint`, because the omitted ones are not
24
+ * absent: the specification gives `destructiveHint` a default of **true** and `idempotentHint`
25
+ * a default of **false**. A tool declaring neither is therefore advertising itself as
26
+ * destructive and unrepeatable — which, for a call that hands back a page of the manual, is
27
+ * simply untrue, and a cautious client is entitled to act on it.
28
+ *
29
+ * `openWorldHint` is false because every answer is bundled with the package. Nothing here
30
+ * reaches the network, so the domain is closed and stays closed offline.
31
+ */
32
+ const READS_ONLY = Object.freeze({
33
+ readOnlyHint: true,
34
+ destructiveHint: false,
35
+ idempotentHint: true,
36
+ openWorldHint: false,
37
+ });
38
+
20
39
  /**
21
40
  * Wraps text as a tool result.
22
41
  *
@@ -209,15 +228,14 @@ async function documentFrom(workspace, { source, path }) {
209
228
  }
210
229
 
211
230
  /**
212
- * Registers every tool on a server.
231
+ * The language in one page, or one section of it.
232
+ *
233
+ * Registered first because it is what the server's instructions tell a model to read first.
213
234
  *
214
235
  * @param {McpServer} server The server.
215
- * @param {Workspace} workspace The patch of filesystem the tools may touch.
216
236
  * @returns {void}
217
237
  */
218
- export function registerTools(server, workspace) {
219
- // --- knowing the language ---------------------------------------------------------------
220
-
238
+ function registerGuide(server) {
221
239
  server.registerTool('forma_guide', {
222
240
  title: 'The forma language in brief',
223
241
  description:
@@ -229,9 +247,20 @@ export function registerTools(server, workspace) {
229
247
  section: z.enum(/** @type {[string, ...string[]]} */ (GUIDE_SECTIONS)).optional()
230
248
  .describe('One section, instead of the whole guide.'),
231
249
  },
232
- annotations: { readOnlyHint: true, openWorldHint: false },
250
+ annotations: READS_ONLY,
233
251
  }, async ({ section }) => attempt(async () => say(guide(section))));
252
+ }
234
253
 
254
+ /**
255
+ * What every block takes, read from the library's own registry.
256
+ *
257
+ * Names are answered in full rather than as a list, because a caller that has to guess at
258
+ * an attribute spends a build finding out it guessed wrong.
259
+ *
260
+ * @param {McpServer} server The server.
261
+ * @returns {void}
262
+ */
263
+ function registerBlocks(server) {
235
264
  server.registerTool('forma_blocks', {
236
265
  title: 'Block and declaration reference',
237
266
  description:
@@ -246,7 +275,7 @@ export function registerTools(server, workspace) {
246
275
  group: z.string().optional()
247
276
  .describe('One group, e.g. "2D shapes", "Transforms", "Refinement", "Declarations".'),
248
277
  },
249
- annotations: { readOnlyHint: true, openWorldHint: false },
278
+ annotations: READS_ONLY,
250
279
  }, async ({ names, group }) => attempt(async () => {
251
280
  const all = [...CONSTRUCTS.values()];
252
281
 
@@ -284,14 +313,25 @@ export function registerTools(server, workspace) {
284
313
  lines.push('_Call again with `names` for the attribute tables._');
285
314
  return say(lines.join('\n'));
286
315
  }));
316
+ }
287
317
 
318
+ /**
319
+ * Every function an expression may call, and the two named constants.
320
+ *
321
+ * There are no user-defined functions in forma, so this answer is complete rather than a
322
+ * sample — which is worth saying, because it is what stops a caller inventing one.
323
+ *
324
+ * @param {McpServer} server The server.
325
+ * @returns {void}
326
+ */
327
+ function registerFunctions(server) {
288
328
  server.registerTool('forma_functions', {
289
329
  title: 'Builtin functions and constants',
290
330
  description:
291
331
  'Every function an expression may call, with its arity, and the two named constants. '
292
332
  + 'There are no user-defined functions in forma, so this is the complete set.',
293
333
  inputSchema: {},
294
- annotations: { readOnlyHint: true, openWorldHint: false },
334
+ annotations: READS_ONLY,
295
335
  }, async () => attempt(async () => say([
296
336
  '# Functions',
297
337
  '',
@@ -306,7 +346,18 @@ export function registerTools(server, workspace) {
306
346
  '',
307
347
  table(['Name', 'Value'], LANGUAGE_CONSTANTS.map((c) => [`\`${c.name}\``, c.value])),
308
348
  ].join('\n'))));
349
+ }
309
350
 
351
+ /**
352
+ * The bundled manual: search it, read a page, or list what there is.
353
+ *
354
+ * The manual ships with the package rather than being fetched, so the answers stay true to
355
+ * the version installed and work with no network at all.
356
+ *
357
+ * @param {McpServer} server The server.
358
+ * @returns {void}
359
+ */
360
+ function registerReference(server) {
310
361
  server.registerTool('forma_reference', {
311
362
  title: 'The reference manual',
312
363
  description:
@@ -318,7 +369,7 @@ export function registerTools(server, workspace) {
318
369
  query: z.string().optional().describe('Words to search for, e.g. "revolve profile axis".'),
319
370
  page: z.string().optional().describe('One page to read, e.g. "reference/conversions.md".'),
320
371
  },
321
- annotations: { readOnlyHint: true, openWorldHint: false },
372
+ annotations: READS_ONLY,
322
373
  }, async ({ query, page }) => attempt(async () => {
323
374
  if (page) {
324
375
  const found = await readPage(page);
@@ -347,7 +398,18 @@ export function registerTools(server, workspace) {
347
398
  table(['Page', 'Title', 'About'], PAGES.map((each) => [`\`${each.path}\``, each.title, each.about])),
348
399
  ].join('\n'));
349
400
  }));
401
+ }
350
402
 
403
+ /**
404
+ * Complete documents that render, to read before writing something new.
405
+ *
406
+ * Every one is rendered by this package's tests at both ends of every parameter's range, so
407
+ * an example cannot teach a mistake without the build failing.
408
+ *
409
+ * @param {McpServer} server The server.
410
+ * @returns {void}
411
+ */
412
+ function registerExamples(server) {
351
413
  server.registerTool('forma_examples', {
352
414
  title: 'Worked documents',
353
415
  description:
@@ -357,7 +419,7 @@ export function registerTools(server, workspace) {
357
419
  inputSchema: {
358
420
  name: z.string().optional().describe('One example to read in full.'),
359
421
  },
360
- annotations: { readOnlyHint: true, openWorldHint: false },
422
+ annotations: READS_ONLY,
361
423
  }, async ({ name }) => attempt(async () => {
362
424
  if (name) {
363
425
  const example = EXAMPLES_BY_NAME.get(name);
@@ -384,7 +446,20 @@ export function registerTools(server, workspace) {
384
446
  }));
385
447
 
386
448
  // --- writing and building ----------------------------------------------------------------
449
+ }
387
450
 
451
+ /**
452
+ * Build a document, and write it only if it built.
453
+ *
454
+ * The measurement is the point, not the file: a caller that is told the bounding box, the
455
+ * volume and the triangle count knows whether the model is the size that was asked for and
456
+ * has not silently solved to nothing. A refusal leaves nothing on disk.
457
+ *
458
+ * @param {McpServer} server The server.
459
+ * @param {Workspace} workspace The patch of filesystem the tool may touch.
460
+ * @returns {void}
461
+ */
462
+ function registerWrite(server, workspace) {
388
463
  server.registerTool('forma_write', {
389
464
  title: 'Check, then write a .forma file',
390
465
  description:
@@ -425,7 +500,19 @@ export function registerTools(server, workspace) {
425
500
  formatReport(report),
426
501
  ].join('\n'));
427
502
  }));
503
+ }
428
504
 
505
+ /**
506
+ * Read a document back, with its current measurements.
507
+ *
508
+ * Source and measurements together, because editing something last touched in another
509
+ * session means needing both.
510
+ *
511
+ * @param {McpServer} server The server.
512
+ * @param {Workspace} workspace The patch of filesystem the tool may touch.
513
+ * @returns {void}
514
+ */
515
+ function registerRead(server, workspace) {
429
516
  server.registerTool('forma_read', {
430
517
  title: 'Read a document back',
431
518
  description:
@@ -437,13 +524,25 @@ export function registerTools(server, workspace) {
437
524
  solve: z.boolean().optional()
438
525
  .describe('Run the geometry kernel. Default true; false parses only, which is faster.'),
439
526
  },
440
- annotations: { readOnlyHint: true, openWorldHint: false },
527
+ annotations: READS_ONLY,
441
528
  }, async ({ path, solve }) => attempt(async () => {
442
529
  const source = await workspace.read(path);
443
530
  const report = await checkDocument(source, { solve: solve ?? true });
444
531
  return say(['```hcl', source.trimEnd(), '```', '', formatReport(report)].join('\n'));
445
532
  }));
533
+ }
446
534
 
535
+ /**
536
+ * Render a document and write one part, or every part unioned, as binary STL.
537
+ *
538
+ * Takes a document either as source or as a path already on disk, so a caller that has just
539
+ * written one does not have to hold it a second time.
540
+ *
541
+ * @param {McpServer} server The server.
542
+ * @param {Workspace} workspace The patch of filesystem the tool may touch.
543
+ * @returns {void}
544
+ */
545
+ function registerExportStl(server, workspace) {
447
546
  server.registerTool('forma_export_stl', {
448
547
  title: 'Render and write a binary STL',
449
548
  description:
@@ -481,3 +580,27 @@ export function registerTools(server, workspace) {
481
580
  );
482
581
  }));
483
582
  }
583
+
584
+ /**
585
+ * Registers every tool on a server.
586
+ *
587
+ * One function per tool, so this reads as the list of what the server offers and each tool's
588
+ * schema, description and handler stay together where they are edited.
589
+ *
590
+ * @param {McpServer} server The server.
591
+ * @param {Workspace} workspace The patch of filesystem the file tools may touch.
592
+ * @returns {void}
593
+ */
594
+ export function registerTools(server, workspace) {
595
+ // Knowing the language.
596
+ registerGuide(server);
597
+ registerBlocks(server);
598
+ registerFunctions(server);
599
+ registerReference(server);
600
+ registerExamples(server);
601
+
602
+ // Writing and building.
603
+ registerWrite(server, workspace);
604
+ registerRead(server, workspace);
605
+ registerExportStl(server, workspace);
606
+ }