forma-dsl-mcp 0.3.7 → 0.3.9
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 +18 -18
- package/package.json +3 -3
- package/src/bin.js +1 -1
- package/src/catalogue.js +15 -15
- package/src/document.js +8 -38
- package/src/examples.js +1 -1
- package/src/guide.js +24 -24
- package/src/reference/README.md +15 -5
- package/src/reference/api/describe-parameters.md +4 -4
- package/src/reference/api/evaluation-context.md +13 -13
- package/src/reference/api/export.md +7 -7
- package/src/reference/api/geometry-node.md +9 -9
- package/src/reference/api/kernel.md +8 -8
- package/src/reference/api/low-level.md +9 -9
- package/src/reference/api/render.md +8 -8
- package/src/reference/api/values.md +12 -12
- package/src/reference/cli.md +146 -0
- package/src/reference/errors.md +14 -14
- package/src/reference/language/control-flow.md +3 -3
- package/src/reference/language/expressions.md +10 -10
- package/src/reference/language/overview.md +11 -11
- package/src/reference/language/scope.md +13 -13
- package/src/reference/language/syntax.md +4 -4
- package/src/reference/reference/align.md +9 -9
- package/src/reference/reference/booleans.md +5 -5
- package/src/reference/reference/component.md +12 -12
- package/src/reference/reference/conversions.md +8 -8
- package/src/reference/reference/functions.md +8 -8
- package/src/reference/reference/local.md +4 -4
- package/src/reference/reference/model.md +10 -10
- package/src/reference/reference/param.md +8 -8
- package/src/reference/reference/part.md +12 -12
- package/src/reference/reference/refinement.md +10 -10
- package/src/reference/reference/shapes-2d.md +15 -15
- package/src/reference/reference/shapes-3d.md +13 -13
- package/src/reference/reference/transforms.md +10 -10
- package/src/reference.js +4 -3
- package/src/roots.js +2 -2
- package/src/server.js +2 -2
- package/src/tools.js +15 -15
- package/src/workspace.js +7 -7
|
@@ -11,7 +11,7 @@ Five leaf blocks producing solids. They take attributes and never contain other
|
|
|
11
11
|
| [`torus`](#torus) | `radius`, `tube_radius` | always |
|
|
12
12
|
|
|
13
13
|
Anything these cannot express is usually a 2D profile plus
|
|
14
|
-
[`extrude`](conversions.md#extrude) or [`revolve`](conversions.md#revolve)
|
|
14
|
+
[`extrude`](conversions.md#extrude) or [`revolve`](conversions.md#revolve) - that is where
|
|
15
15
|
the real modelling happens.
|
|
16
16
|
|
|
17
17
|
---
|
|
@@ -35,7 +35,7 @@ box { size = [40, 20, 10] center = true }
|
|
|
35
35
|
#### Caveats
|
|
36
36
|
|
|
37
37
|
- A single number is accepted as shorthand for all three, so `size = 10` is a 10 mm cube.
|
|
38
|
-
- There is no rounded box. Round a 2D profile and extrude it
|
|
38
|
+
- There is no rounded box. Round a 2D profile and extrude it -
|
|
39
39
|
see [Usage](#rounded-boxes).
|
|
40
40
|
|
|
41
41
|
---
|
|
@@ -52,7 +52,7 @@ sphere { diameter = 24 segments = 64 }
|
|
|
52
52
|
| Attribute | Type | Default | Description |
|
|
53
53
|
| --- | --- | --- | --- |
|
|
54
54
|
| `radius` | number | `1` | |
|
|
55
|
-
| `diameter` | number |
|
|
55
|
+
| `diameter` | number | - | An alternative spelling of the same thing |
|
|
56
56
|
| `segments` | integer | `0` | Segments around the equator; `0` lets the kernel choose |
|
|
57
57
|
|
|
58
58
|
#### Caveats
|
|
@@ -77,17 +77,17 @@ cylinder { bottom_radius = 12 top_radius = 8 height = 15 }
|
|
|
77
77
|
| Attribute | Type | Default | Description |
|
|
78
78
|
| --- | --- | --- | --- |
|
|
79
79
|
| `height` | number | **required** | Along Z |
|
|
80
|
-
| `radius` | number |
|
|
81
|
-
| `diameter` | number |
|
|
82
|
-
| `bottom_radius` | number |
|
|
83
|
-
| `top_radius` | number |
|
|
80
|
+
| `radius` | number | - | Both ends alike |
|
|
81
|
+
| `diameter` | number | - | An alternative spelling of `radius` |
|
|
82
|
+
| `bottom_radius` | number | - | The end at Z = 0 |
|
|
83
|
+
| `top_radius` | number | - | The end at Z = `height` |
|
|
84
84
|
| `segments` | integer | `0` | Segments around; `0` lets the kernel choose |
|
|
85
85
|
| `center` | bool | `false` | `true` centres it on Z = 0; otherwise it stands on the XY plane |
|
|
86
86
|
|
|
87
87
|
#### Caveats
|
|
88
88
|
|
|
89
89
|
- Give **either** `radius`/`diameter`, **or both** `bottom_radius` and `top_radius`. Anything
|
|
90
|
-
else is `cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"`
|
|
90
|
+
else is `cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"` -
|
|
91
91
|
one radius on its own is ambiguous, and there is no default.
|
|
92
92
|
- Unlike every other shape, `radius` has no usable default here: the check runs before the
|
|
93
93
|
fallback would apply.
|
|
@@ -110,7 +110,7 @@ cone { radius = 10 top_radius = 4 height = 20 }
|
|
|
110
110
|
| --- | --- | --- | --- |
|
|
111
111
|
| `height` | number | **required** | Along Z |
|
|
112
112
|
| `radius` | number | `1` | The base, at Z = 0 |
|
|
113
|
-
| `diameter` | number |
|
|
113
|
+
| `diameter` | number | - | An alternative spelling of `radius` |
|
|
114
114
|
| `top_radius` | number | `0` | `0` gives a point |
|
|
115
115
|
| `segments` | integer | `0` | Segments around |
|
|
116
116
|
| `center` | bool | `false` | As for [`cylinder`](#cylinder) |
|
|
@@ -118,7 +118,7 @@ cone { radius = 10 top_radius = 4 height = 20 }
|
|
|
118
118
|
#### Caveats
|
|
119
119
|
|
|
120
120
|
- `cone` and `cylinder` build the same kind of solid; they differ only in which attributes
|
|
121
|
-
are required and what `top_radius` defaults to. Use whichever reads better
|
|
121
|
+
are required and what `top_radius` defaults to. Use whichever reads better - `cone` when
|
|
122
122
|
the top is a point, `cylinder` when both ends matter.
|
|
123
123
|
|
|
124
124
|
---
|
|
@@ -158,7 +158,7 @@ torus { radius = 20 tube_radius = 4 angle = 180 }
|
|
|
158
158
|
|
|
159
159
|
### Rounded boxes
|
|
160
160
|
|
|
161
|
-
There is no rounded-box primitive. Round the 2D profile and extrude it
|
|
161
|
+
There is no rounded-box primitive. Round the 2D profile and extrude it - which also gives
|
|
162
162
|
control over whether the vertical edges or the horizontal ones are rounded:
|
|
163
163
|
|
|
164
164
|
```hcl
|
|
@@ -270,8 +270,8 @@ box { size = [40, 20, 10] } // ✓
|
|
|
270
270
|
### My cylinder is not where I expect
|
|
271
271
|
|
|
272
272
|
By default a cylinder stands **on** the XY plane, with its base at Z = 0. `center = true`
|
|
273
|
-
puts its middle there instead. The same is true of `box`, whose minimum corner
|
|
274
|
-
centre
|
|
273
|
+
puts its middle there instead. The same is true of `box`, whose minimum corner - not its
|
|
274
|
+
centre - sits at the origin by default.
|
|
275
275
|
|
|
276
276
|
### Two solids that touch exactly render with artefacts
|
|
277
277
|
|
|
@@ -36,7 +36,7 @@ translate { offset = [10, 0] rect { size = [4, 4] } } // 2D
|
|
|
36
36
|
|
|
37
37
|
#### Caveats
|
|
38
38
|
|
|
39
|
-
- `offset` must have as many components as the child has dimensions
|
|
39
|
+
- `offset` must have as many components as the child has dimensions - 2 for a cross-section,
|
|
40
40
|
3 for a solid. A mismatch is `translate: "offset" must be a number or an array of 3
|
|
41
41
|
numbers`.
|
|
42
42
|
- A single number broadcasts to every axis, which is rarely what you want here but is
|
|
@@ -54,14 +54,14 @@ rotate { angles = [0, 90, 45] box { size = [20, 4, 4] } } // 3D, all three
|
|
|
54
54
|
rotate { angle = 30 rect { size = [20, 4] } } // 2D
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
#### Attributes
|
|
57
|
+
#### Attributes - 3D children
|
|
58
58
|
|
|
59
59
|
| Attribute | Type | Default | Description |
|
|
60
60
|
| --- | --- | --- | --- |
|
|
61
|
-
| `angles` | 3-vector |
|
|
61
|
+
| `angles` | 3-vector | - | Degrees about X, Y and Z at once |
|
|
62
62
|
| `x`, `y`, `z` | number | `0` | Degrees about that axis, named one at a time |
|
|
63
63
|
|
|
64
|
-
#### Attributes
|
|
64
|
+
#### Attributes - 2D children
|
|
65
65
|
|
|
66
66
|
| Attribute | Type | Default | Description |
|
|
67
67
|
| --- | --- | --- | --- |
|
|
@@ -70,7 +70,7 @@ rotate { angle = 30 rect { size = [20, 4] } } // 2D
|
|
|
70
70
|
#### Caveats
|
|
71
71
|
|
|
72
72
|
- **Rotation is about the origin, not about the geometry.** To spin something in place, put
|
|
73
|
-
it at the origin, rotate, then translate
|
|
73
|
+
it at the origin, rotate, then translate - or build it centred in the first place.
|
|
74
74
|
- In 3D the axes are applied **X, then Y, then Z**, matching OpenSCAD, so a model ported from
|
|
75
75
|
there lands in the same orientation. Composing rotations in a different order requires
|
|
76
76
|
separate `rotate` blocks.
|
|
@@ -100,7 +100,7 @@ scale { factor = [1, 1, 0.5] sphere { radius = 5 } } // flattened
|
|
|
100
100
|
- A negative factor mirrors as well as scales, and flips the winding. Prefer
|
|
101
101
|
[`mirror`](#mirror) when reflection is what you mean.
|
|
102
102
|
- A factor of `0` collapses the geometry to nothing.
|
|
103
|
-
- Non-uniform scaling of a curved surface changes its radii
|
|
103
|
+
- Non-uniform scaling of a curved surface changes its radii - a scaled sphere is an
|
|
104
104
|
ellipsoid, which is the cheapest way to get one.
|
|
105
105
|
|
|
106
106
|
---
|
|
@@ -167,7 +167,7 @@ translate { // …then moved 50 along X
|
|
|
167
167
|
}
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
Swapping the two gives a different result
|
|
170
|
+
Swapping the two gives a different result - rotating a translated box sweeps it around the
|
|
171
171
|
origin on an arc of radius 50.
|
|
172
172
|
|
|
173
173
|
### Radial patterns
|
|
@@ -183,7 +183,7 @@ for i in range(0, 8) {
|
|
|
183
183
|
}
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
Because identity is content identity, this costs **one** cylinder and eight transforms
|
|
186
|
+
Because identity is content identity, this costs **one** cylinder and eight transforms - the
|
|
187
187
|
cylinder node is the same node every time round the loop.
|
|
188
188
|
|
|
189
189
|
### Mirrored pairs
|
|
@@ -212,7 +212,7 @@ scale { factor = [1, 0.6, 1] sphere { radius = 20 segments = 64 } }
|
|
|
212
212
|
### `translate: "offset" must be a number or an array of 3 numbers`
|
|
213
213
|
|
|
214
214
|
The offset has the wrong number of components for its child. 2D children want two, 3D
|
|
215
|
-
children want three
|
|
215
|
+
children want three - and a 2D shape stays 2D until it is extruded:
|
|
216
216
|
|
|
217
217
|
```hcl
|
|
218
218
|
translate { offset = [1, 2, 3] rect { size = [4, 4] } } // ✗ rect is 2D
|
|
@@ -228,7 +228,7 @@ rotate { angle = 45 box { size = [1, 1, 1] } } // ✗
|
|
|
228
228
|
rotate { z = 45 box { size = [1, 1, 1] } } // ✓
|
|
229
229
|
```
|
|
230
230
|
|
|
231
|
-
The reverse
|
|
231
|
+
The reverse - `z` on a 2D child - fails the same way.
|
|
232
232
|
|
|
233
233
|
### `"translate" needs at least one shape inside it`
|
|
234
234
|
|
package/src/reference.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The manual is the language's own documentation, copied into the package by
|
|
5
5
|
* `scripts/sync-reference.mjs` rather than paraphrased, so an answer from here is the same
|
|
6
|
-
* answer a person reading the published docs would get. Search is deliberately simple
|
|
7
|
-
* substring matching over headings and body text, ranked
|
|
6
|
+
* answer a person reading the published docs would get. Search is deliberately simple -
|
|
7
|
+
* substring matching over headings and body text, ranked - because the corpus is 29 pages
|
|
8
8
|
* and anything cleverer would be a dependency and a source of surprise.
|
|
9
9
|
*/
|
|
10
10
|
import { readFile, readdir } from 'node:fs/promises';
|
|
@@ -47,6 +47,7 @@ export const PAGES = [
|
|
|
47
47
|
{ path: 'reference/part.md', title: 'part', about: 'Coloured pieces of the rendered scene' },
|
|
48
48
|
{ path: 'reference/functions.md', title: 'Functions', about: 'Every builtin function and constant' },
|
|
49
49
|
{ path: 'errors.md', title: 'Errors', about: 'Every error message, what raises it, and what to change' },
|
|
50
|
+
{ path: 'cli.md', title: 'Command line', about: 'The forma command: render to STL, list params and models, exit codes' },
|
|
50
51
|
{ path: 'api/render.md', title: 'render', about: 'JavaScript API: compile a document and evaluate a model' },
|
|
51
52
|
{ path: 'api/describe-parameters.md', title: 'describeParameters', about: 'JavaScript API: read a document’s params without rendering' },
|
|
52
53
|
{ path: 'api/evaluation-context.md', title: 'EvaluationContext', about: 'JavaScript API: the digest cache and WASM ownership' },
|
|
@@ -95,7 +96,7 @@ export async function readPage(path) {
|
|
|
95
96
|
const page = BY_PATH.get(normalised);
|
|
96
97
|
if (!page) {
|
|
97
98
|
throw new Error(
|
|
98
|
-
`no reference page "${path}"
|
|
99
|
+
`no reference page "${path}" - the pages are ${PAGES.map((each) => each.path).join(', ')}`,
|
|
99
100
|
);
|
|
100
101
|
}
|
|
101
102
|
return { path: page.path, title: page.title, markdown: await readFile(join(ROOT, page.path), 'utf8') };
|
package/src/roots.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The server does not get to choose the patch of disk it writes to. The MCP `roots`
|
|
5
5
|
* capability is the client's declaration of which directories it is handing over, and asking
|
|
6
|
-
* for it
|
|
6
|
+
* for it - rather than reading an environment variable the client cannot see - is the whole
|
|
7
7
|
* of this module. A client that declares nothing gets the directory it launched the server
|
|
8
8
|
* in, which is the one location it did choose.
|
|
9
9
|
*/
|
|
@@ -17,7 +17,7 @@ import { Workspace } from './workspace.js';
|
|
|
17
17
|
* The part of the low-level server this module needs.
|
|
18
18
|
*
|
|
19
19
|
* Named structurally rather than as the SDK's `Server`, which is deprecated in favour of
|
|
20
|
-
* `McpServer`
|
|
20
|
+
* `McpServer` - but `McpServer` does not carry `listRoots` or `setNotificationHandler`, so the
|
|
21
21
|
* low-level object is genuinely the right one to take. Describing the three members used is
|
|
22
22
|
* both the honest contract and what lets a test hand this a stub.
|
|
23
23
|
*
|
package/src/server.js
CHANGED
|
@@ -43,7 +43,7 @@ export const VERSION = createRequire(import.meta.url)('../package.json').version
|
|
|
43
43
|
const INSTRUCTIONS = `This server knows forma, a declarative DSL for 3D solids, builds it with
|
|
44
44
|
the real geometry kernel, and writes the documents it builds.
|
|
45
45
|
|
|
46
|
-
Start with \`forma_guide\`
|
|
46
|
+
Start with \`forma_guide\` - it is the language in one page. Use \`forma_blocks\` for the
|
|
47
47
|
attributes of the blocks you are about to write, \`forma_examples\` for a working document of
|
|
48
48
|
a similar shape, and \`forma_reference\` when you need the manual.
|
|
49
49
|
|
|
@@ -61,7 +61,7 @@ first of them. Every write reports the directory it landed in.`;
|
|
|
61
61
|
* Builds the server, with every tool and resource registered.
|
|
62
62
|
*
|
|
63
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
|
|
64
|
+
* directories from whatever the connected client declares as its roots, which is the point -
|
|
65
65
|
* the client knows where the user's files belong and the server does not.
|
|
66
66
|
*
|
|
67
67
|
* @param {Workspace} [workspace] The patch of filesystem the tools may touch. Omitted, the
|
package/src/tools.js
CHANGED
|
@@ -23,7 +23,7 @@ import { checkDocument, exportStl } from './document.js';
|
|
|
23
23
|
* All four hints are stated rather than just `readOnlyHint`, because the omitted ones are not
|
|
24
24
|
* absent: the specification gives `destructiveHint` a default of **true** and `idempotentHint`
|
|
25
25
|
* a default of **false**. A tool declaring neither is therefore advertising itself as
|
|
26
|
-
* destructive and unrepeatable
|
|
26
|
+
* destructive and unrepeatable - which, for a call that hands back a page of the manual, is
|
|
27
27
|
* simply untrue, and a cautious client is entitled to act on it.
|
|
28
28
|
*
|
|
29
29
|
* `openWorldHint` is false because every answer is bundled with the package. Nothing here
|
|
@@ -52,8 +52,8 @@ const say = (text, isError = false) => ({
|
|
|
52
52
|
* Runs a handler, turning anything it throws into a result the caller can read.
|
|
53
53
|
*
|
|
54
54
|
* A thrown error becomes a protocol-level failure, which most clients surface as "the tool
|
|
55
|
-
* broke" rather than as something the model can correct. A refusal it can read
|
|
56
|
-
* outside the roots, a part that does not exist
|
|
55
|
+
* broke" rather than as something the model can correct. A refusal it can read - a path
|
|
56
|
+
* outside the roots, a part that does not exist - belongs in the result instead.
|
|
57
57
|
*
|
|
58
58
|
* @param {() => Promise<object>} handler What to run.
|
|
59
59
|
* @returns {Promise<object>} Its result, or the failure as text.
|
|
@@ -115,9 +115,9 @@ function parameterLines(parameters) {
|
|
|
115
115
|
return [
|
|
116
116
|
`\`${parameter.name}\``,
|
|
117
117
|
parameter.type,
|
|
118
|
-
parameter.default === undefined ? '
|
|
118
|
+
parameter.default === undefined ? '-' : JSON.stringify(parameter.default),
|
|
119
119
|
parameter.required ? 'yes' : 'no',
|
|
120
|
-
bounded ? `${parameter.min ?? '−∞'} … ${parameter.max ?? '∞'}` : '
|
|
120
|
+
bounded ? `${parameter.min ?? '−∞'} … ${parameter.max ?? '∞'}` : '-',
|
|
121
121
|
];
|
|
122
122
|
}),
|
|
123
123
|
), ''];
|
|
@@ -184,7 +184,7 @@ function sceneLines(scene, stats) {
|
|
|
184
184
|
function takesOf(entry) {
|
|
185
185
|
if (entry.leaf) return 'nothing';
|
|
186
186
|
if (entry.takes === 'same') return '2D or 3D';
|
|
187
|
-
return entry.takes ? `${entry.takes}D` : '
|
|
187
|
+
return entry.takes ? `${entry.takes}D` : '-';
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/**
|
|
@@ -224,7 +224,7 @@ async function documentFrom(workspace, { source, path }) {
|
|
|
224
224
|
if (source && path) throw new Error('give either "source" or "path", not both');
|
|
225
225
|
if (source) return source;
|
|
226
226
|
if (path) return workspace.read(path);
|
|
227
|
-
throw new Error('give either "source"
|
|
227
|
+
throw new Error('give either "source" - the document itself - or "path" to one already written');
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
@@ -304,7 +304,7 @@ function registerBlocks(server) {
|
|
|
304
304
|
['Block', 'Dim', 'Takes', 'Summary'],
|
|
305
305
|
all.filter((entry) => entry.group === name).map((entry) => [
|
|
306
306
|
`\`${entry.name}\``,
|
|
307
|
-
entry.dim ? `${entry.dim}D` : '
|
|
307
|
+
entry.dim ? `${entry.dim}D` : '-',
|
|
308
308
|
takesOf(entry),
|
|
309
309
|
entry.summary,
|
|
310
310
|
]),
|
|
@@ -319,7 +319,7 @@ function registerBlocks(server) {
|
|
|
319
319
|
* Every function an expression may call, and the two named constants.
|
|
320
320
|
*
|
|
321
321
|
* There are no user-defined functions in forma, so this answer is complete rather than a
|
|
322
|
-
* sample
|
|
322
|
+
* sample - which is worth saying, because it is what stops a caller inventing one.
|
|
323
323
|
*
|
|
324
324
|
* @param {McpServer} server The server.
|
|
325
325
|
* @returns {void}
|
|
@@ -363,7 +363,7 @@ function registerReference(server) {
|
|
|
363
363
|
description:
|
|
364
364
|
'The language\'s full reference manual, bundled with this server. Pass `query` to '
|
|
365
365
|
+ 'search it, or `page` to read one page whole. Reach for this when forma_blocks is not '
|
|
366
|
-
+ 'enough
|
|
366
|
+
+ 'enough - the manual has the worked examples, the caveats and the troubleshooting for '
|
|
367
367
|
+ 'every error message the language raises. Call with no arguments to list the pages.',
|
|
368
368
|
inputSchema: {
|
|
369
369
|
query: z.string().optional().describe('Words to search for, e.g. "revolve profile axis".'),
|
|
@@ -383,7 +383,7 @@ function registerReference(server) {
|
|
|
383
383
|
`# ${hits.length} page${plural} matching "${query}"`,
|
|
384
384
|
'',
|
|
385
385
|
...hits.map((hit) => [
|
|
386
|
-
`## ${hit.title}
|
|
386
|
+
`## ${hit.title} - \`${hit.path}\``,
|
|
387
387
|
hit.heading ? `_under "${hit.heading}"_` : '',
|
|
388
388
|
'',
|
|
389
389
|
'> ' + hit.snippet.replaceAll('\n', '\n> '),
|
|
@@ -468,7 +468,7 @@ function registerWrite(server, workspace) {
|
|
|
468
468
|
+ 'triangle count and genus, or the error with the line, the column and an excerpt with '
|
|
469
469
|
+ 'a caret on it. This is how you find out whether what you wrote is the size you meant, '
|
|
470
470
|
+ 'is watertight, and is not silently empty. A document that does not render is refused '
|
|
471
|
-
+ 'and nothing is written
|
|
471
|
+
+ 'and nothing is written - pass `force: true` to write it anyway. Refuses to replace an '
|
|
472
472
|
+ 'existing file unless `overwrite` is set.',
|
|
473
473
|
inputSchema: {
|
|
474
474
|
path: z.string()
|
|
@@ -494,7 +494,7 @@ function registerWrite(server, workspace) {
|
|
|
494
494
|
|
|
495
495
|
const written = await workspace.write(path, source, { overwrite, extension: '.forma' });
|
|
496
496
|
return say([
|
|
497
|
-
`Wrote \`${written.relative}\` under \`${written.root}\`
|
|
497
|
+
`Wrote \`${written.relative}\` under \`${written.root}\` - ${written.bytes} bytes`
|
|
498
498
|
+ `${written.replaced ? ', replacing what was there' : ''}.`,
|
|
499
499
|
'',
|
|
500
500
|
formatReport(report),
|
|
@@ -546,7 +546,7 @@ function registerExportStl(server, workspace) {
|
|
|
546
546
|
server.registerTool('forma_export_stl', {
|
|
547
547
|
title: 'Render and write a binary STL',
|
|
548
548
|
description:
|
|
549
|
-
'Renders a document and writes one part
|
|
549
|
+
'Renders a document and writes one part - or every part unioned - as a binary STL. '
|
|
550
550
|
+ 'Lengths are unitless in forma and STL has no unit either; every consumer treats them '
|
|
551
551
|
+ 'as millimetres.',
|
|
552
552
|
inputSchema: {
|
|
@@ -575,7 +575,7 @@ function registerExportStl(server, workspace) {
|
|
|
575
575
|
extension: '.stl',
|
|
576
576
|
});
|
|
577
577
|
return say(
|
|
578
|
-
`Wrote \`${written.relative}\` under \`${written.root}\`
|
|
578
|
+
`Wrote \`${written.relative}\` under \`${written.root}\` - ${written.bytes} bytes, `
|
|
579
579
|
+ `${result.triangles} triangles, from model \`${result.model}\`, part \`${result.part}\`.`,
|
|
580
580
|
);
|
|
581
581
|
}));
|
package/src/workspace.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* An MCP server writes files on behalf of a model, so the set of paths it will accept is a
|
|
5
5
|
* security boundary rather than a convenience. Which directories make up that set is the
|
|
6
|
-
* client's decision
|
|
6
|
+
* client's decision - it declares them as MCP roots, and {@link Workspace} only asks. What
|
|
7
7
|
* the workspace enforces is that everything goes through `resolve`, which refuses anything
|
|
8
8
|
* landing outside all of them, including by way of `..`, an absolute path, or a symlink
|
|
9
9
|
* pointing out of the tree.
|
|
@@ -84,7 +84,7 @@ export class Workspace {
|
|
|
84
84
|
*
|
|
85
85
|
* A root is resolved once and remembered, so a root that is itself a link still compares
|
|
86
86
|
* equal to the paths resolved beneath it. Roots that do not exist are dropped rather than
|
|
87
|
-
* fatal
|
|
87
|
+
* fatal - a client may well declare a directory this server has no business in - and only
|
|
88
88
|
* an empty result is an error.
|
|
89
89
|
*
|
|
90
90
|
* @returns {Promise<Root[]>} The roots, in the order the client gave them.
|
|
@@ -124,7 +124,7 @@ export class Workspace {
|
|
|
124
124
|
if (!roots.length) {
|
|
125
125
|
throw new WorkspaceError(
|
|
126
126
|
refused.length
|
|
127
|
-
? `no usable workspace root
|
|
127
|
+
? `no usable workspace root - ${refused.join('; ')}`
|
|
128
128
|
: 'the client has declared no workspace root, so there is nowhere to read or write; '
|
|
129
129
|
+ 'declare one with the MCP roots capability',
|
|
130
130
|
);
|
|
@@ -185,7 +185,7 @@ export class Workspace {
|
|
|
185
185
|
// the caller gave is used to choose segments and never to name a file. Each segment has
|
|
186
186
|
// to match SAFE_SEGMENT, and what comes out is assembled onto a directory the client
|
|
187
187
|
// declared. Every line below, and every caller, works with that value rather than the one
|
|
188
|
-
// that came in
|
|
188
|
+
// that came in - so the walk beneath cannot be steered by a name this never approved.
|
|
189
189
|
const base = this.#baseOf(root, lexical);
|
|
190
190
|
const segments = relative(base, lexical).split(sep).filter(Boolean);
|
|
191
191
|
if (!segments.every((segment) => SAFE_SEGMENT.test(segment))) {
|
|
@@ -193,7 +193,7 @@ export class Workspace {
|
|
|
193
193
|
}
|
|
194
194
|
const absolute = segments.reduce((at, segment) => join(at, segment), base);
|
|
195
195
|
|
|
196
|
-
// The file itself may not exist yet
|
|
196
|
+
// The file itself may not exist yet - a write is the common case - so the deepest
|
|
197
197
|
// ancestor that does exist is what gets its links followed. This is the one check that
|
|
198
198
|
// cannot be made without the filesystem: whether a directory inside a root is a link out
|
|
199
199
|
// of it is not answerable from the path alone.
|
|
@@ -208,7 +208,7 @@ export class Workspace {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
|
-
* Which spelling of a root a path sits under
|
|
211
|
+
* Which spelling of a root a path sits under - the one the client gave, or the one symlinks
|
|
212
212
|
* resolve it to.
|
|
213
213
|
*
|
|
214
214
|
* @param {Root} root The root holding it.
|
|
@@ -294,7 +294,7 @@ export class Workspace {
|
|
|
294
294
|
|
|
295
295
|
const replaced = existsSync(absolute);
|
|
296
296
|
if (replaced && !overwrite) {
|
|
297
|
-
throw new WorkspaceError(`"${path}" already exists
|
|
297
|
+
throw new WorkspaceError(`"${path}" already exists - pass overwrite: true to replace it`);
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
await mkdir(dirname(absolute), { recursive: true });
|