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/README.md +180 -38
- package/package.json +5 -5
- package/src/bin.js +16 -12
- package/src/document.js +15 -2
- package/src/reference/errors.md +9 -3
- package/src/reference.js +10 -8
- package/src/roots.js +81 -0
- package/src/server.js +38 -96
- package/src/tools.js +155 -91
- package/src/workspace.js +194 -92
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
|
-
/**
|
|
20
|
-
|
|
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,
|
|
29
|
-
and
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
61
|
+
* Builds the server, with every tool and resource registered.
|
|
43
62
|
*
|
|
44
|
-
*
|
|
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: {}
|
|
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
|
|
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
|
|
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<
|
|
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
|
|
208
|
-
if (path) return
|
|
209
|
-
throw new Error('give either "source" — the document itself — or "path" to one
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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: '
|
|
445
|
+
title: 'Check, then write a .forma file',
|
|
428
446
|
description:
|
|
429
|
-
'
|
|
430
|
-
+ '
|
|
431
|
-
+ '
|
|
432
|
-
+ '
|
|
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()
|
|
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
|
|
437
|
-
params: z.record(z.string(), z.any()).optional()
|
|
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}\`
|
|
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
|
|
463
|
-
description:
|
|
464
|
-
|
|
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
|
|
467
|
-
solve: z.boolean().optional()
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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: '
|
|
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
|
|
497
|
-
+ '
|
|
498
|
-
+ '
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
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}\`
|
|
520
|
-
+
|
|
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
|
+
}
|