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 +16 -0
- package/package.json +2 -2
- package/src/document.js +15 -2
- package/src/tools.js +134 -11
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/kalpak44/forma-dsl/actions/workflows/release.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/forma-dsl-mcp)
|
|
5
|
+
[](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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|