forma-dsl-mcp 0.1.5 → 0.2.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 +180 -38
- package/package.json +5 -5
- package/src/bin.js +16 -12
- package/src/reference/errors.md +9 -3
- package/src/reference.js +17 -10
- package/src/roots.js +81 -0
- package/src/server.js +38 -96
- package/src/tools.js +47 -87
- package/src/workspace.js +194 -92
package/README.md
CHANGED
|
@@ -5,93 +5,235 @@
|
|
|
5
5
|
|
|
6
6
|
A [Model Context Protocol](https://modelcontextprotocol.io) server that knows
|
|
7
7
|
[forma](https://www.npmjs.com/package/forma-dsl) — the declarative DSL for 3D solids — and
|
|
8
|
-
|
|
8
|
+
builds it with the real geometry kernel, so a `.forma` document is measured before anyone is
|
|
9
|
+
told it works.
|
|
9
10
|
|
|
10
|
-
It
|
|
11
|
+
It carries the language with it: the guide, the block reference, the worked examples and the
|
|
12
|
+
full manual are bundled, so a model learns the syntax from the server rather than guessing at
|
|
13
|
+
it.
|
|
14
|
+
|
|
15
|
+
It speaks over stdio, and there is nothing to configure.
|
|
16
|
+
|
|
17
|
+
## Installing it
|
|
18
|
+
|
|
19
|
+
**Claude Code**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
claude mcp add forma -- npx -y forma-dsl-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Codex**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
codex mcp add forma -- npx -y forma-dsl-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or, in `~/.codex/config.toml`:
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
[mcp_servers.forma]
|
|
35
|
+
command = "npx"
|
|
36
|
+
args = ["-y", "forma-dsl-mcp"]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Claude Desktop, and anything else that takes a JSON block**
|
|
11
40
|
|
|
12
41
|
```json
|
|
13
42
|
{
|
|
14
43
|
"mcpServers": {
|
|
15
44
|
"forma": {
|
|
16
45
|
"command": "npx",
|
|
17
|
-
"args": ["-y", "forma-dsl-mcp"]
|
|
18
|
-
"env": { "FORMA_MCP_ROOT": "/absolute/path/to/your/models" }
|
|
46
|
+
"args": ["-y", "forma-dsl-mcp"]
|
|
19
47
|
}
|
|
20
48
|
}
|
|
21
49
|
}
|
|
22
50
|
```
|
|
23
51
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
## Where the files go
|
|
53
|
+
|
|
54
|
+
The server does not choose. It asks the client over MCP's
|
|
55
|
+
[roots](https://modelcontextprotocol.io/specification/basic/roots) capability, and reads and
|
|
56
|
+
writes only inside the directories the client hands over. A relative path is taken against
|
|
57
|
+
the first of them, and every write reports the directory it landed in.
|
|
58
|
+
|
|
59
|
+
**Every path is confined to those directories** — an absolute path outside them, a `..` that
|
|
60
|
+
climbs out, or a symlink pointing out of the tree is refused rather than followed. When the
|
|
61
|
+
client says its roots have changed, the server re-reads them before the next call.
|
|
62
|
+
|
|
63
|
+
A client that does not implement roots gets the working directory it launched the server in,
|
|
64
|
+
which is the one location it did choose. Codex can set that explicitly:
|
|
65
|
+
|
|
66
|
+
```toml
|
|
67
|
+
[mcp_servers.forma]
|
|
68
|
+
command = "npx"
|
|
69
|
+
args = ["-y", "forma-dsl-mcp"]
|
|
70
|
+
cwd = "/absolute/path/to/your/models"
|
|
71
|
+
```
|
|
28
72
|
|
|
29
73
|
## Why it is not just a prompt
|
|
30
74
|
|
|
31
75
|
A model that has read the language's documentation still writes forma that does not build.
|
|
32
|
-
The difference this server makes is that it **runs the geometry kernel**.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`forma_write`
|
|
76
|
+
The difference this server makes is that it **runs the geometry kernel**. Every tool compiles
|
|
77
|
+
the document, solves it with [Manifold](https://github.com/elalish/manifold) and reports back
|
|
78
|
+
the bounding box, the volume, the triangle count and the genus of every part — so the
|
|
79
|
+
question "is this the size that was asked for, and is it actually a solid?" is answered
|
|
80
|
+
before the work is handed over. A part that silently solved to nothing is called out by name,
|
|
81
|
+
and an error comes back with the line, the column and an excerpt with a caret on it.
|
|
82
|
+
|
|
83
|
+
`forma_write` **refuses to write a document that does not render**.
|
|
84
|
+
|
|
85
|
+
Refusals carry the vocabulary too: an unknown block comes back with every block there is, and
|
|
86
|
+
an unknown attribute with the ones that block reads. That is the backstop, not the teacher —
|
|
87
|
+
it is enough to correct a misspelling and not enough to learn a language, since it gives the
|
|
88
|
+
name of a block and nothing about what that block means or what it takes. `forma_guide`,
|
|
89
|
+
`forma_blocks` and `forma_reference` are what answer that, and they are bundled so they
|
|
90
|
+
answer offline.
|
|
40
91
|
|
|
41
92
|
Everything the server says about the language is answerable to the language. The block and
|
|
42
93
|
function catalogue is merged with the library's own registries at load time, and the tests
|
|
43
|
-
assert
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
range.
|
|
94
|
+
assert the two agree: a block added, renamed or given a new dimensionality in `forma-dsl`
|
|
95
|
+
fails this package's build rather than becoming a confident wrong answer. Every worked example
|
|
96
|
+
is rendered by the test suite.
|
|
47
97
|
|
|
48
98
|
## Tools
|
|
49
99
|
|
|
100
|
+
**Learning the language**
|
|
101
|
+
|
|
50
102
|
| Tool | What it does |
|
|
51
103
|
| --- | --- |
|
|
52
104
|
| `forma_guide` | The language in one page: structure, nesting, dimensionality, units, syntax, the mistakes that cost the most time |
|
|
53
105
|
| `forma_blocks` | Every block with its attributes, defaults, dimensionality and caveats |
|
|
54
106
|
| `forma_functions` | Every builtin function and constant, with arity |
|
|
55
|
-
| `forma_reference` | The full reference manual, searchable and readable page by page |
|
|
56
107
|
| `forma_examples` | Complete documents that render, each showing one way of building a real part |
|
|
57
|
-
| `
|
|
58
|
-
|
|
108
|
+
| `forma_reference` | The full reference manual, searchable and readable page by page |
|
|
109
|
+
|
|
110
|
+
**Building with it**
|
|
111
|
+
|
|
112
|
+
| Tool | What it does |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `forma_write` | Check, then write a `.forma` file |
|
|
59
115
|
| `forma_read` | Read a document back, checked |
|
|
60
|
-
| `forma_list` | Every `.forma` file under the root |
|
|
61
116
|
| `forma_export_stl` | Render and write a binary STL |
|
|
62
117
|
|
|
63
|
-
## Resources
|
|
118
|
+
## Resources
|
|
64
119
|
|
|
65
|
-
|
|
120
|
+
The same knowledge in the form a person can attach to a conversation by hand:
|
|
66
121
|
|
|
67
122
|
- `forma://guide` — the language in brief
|
|
68
123
|
- `forma://catalogue` — every block and function, as JSON
|
|
69
124
|
- `forma://reference/{path}` — one page of the manual
|
|
70
125
|
- `forma://example/{name}` — one worked document
|
|
71
126
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
127
|
+
## Asking for a part
|
|
128
|
+
|
|
129
|
+
Install it in Codex as above, `cd` to wherever you keep your models, and ask:
|
|
130
|
+
|
|
131
|
+
> **you** — build me a forma file with a tree in it
|
|
132
|
+
|
|
133
|
+
> **codex** — Where should I put it?
|
|
134
|
+
|
|
135
|
+
> **you** — the current folder
|
|
136
|
+
|
|
137
|
+
Codex reads the language before it writes any: `forma_guide` for the shape of a document,
|
|
138
|
+
`forma_blocks` for the attributes of the blocks it is about to use — here `cone`, `translate`
|
|
139
|
+
and `for` — and `forma_examples` for a document of a similar shape. Then it writes, and
|
|
140
|
+
`forma_write` builds what it wrote with the real kernel before anything reaches the disk.
|
|
141
|
+
|
|
142
|
+
Where the vocabulary runs out, the refusals carry it. A guess at a block that does not exist
|
|
143
|
+
comes back with every block that does, and a guess at an attribute with the ones that block
|
|
144
|
+
reads:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
unknown block "frustum" — the blocks are "align", "for", "if", "part", "box", "circle",
|
|
148
|
+
"cone", "cylinder", "difference", … "union"
|
|
149
|
+
|
|
150
|
+
cone: unknown attribute "bottom_radius" — cone reads "center", "height", "radius",
|
|
151
|
+
"segments", "top_radius"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Then it lands, and the tool answers with the part rather than with a claim about it:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
Wrote `tree.forma` under `/home/you/models` — 537 bytes.
|
|
158
|
+
|
|
159
|
+
| Part | Colour | Opacity | Size (X × Y × Z) | Volume | Triangles | Genus |
|
|
160
|
+
| `trunk` | #6b4423 | 1 | 8 × 8 × 30 | 1004.682 | 108 | 0 |
|
|
161
|
+
| `canopy` | #2f7d32 | 1 | 28 × 28 × 30 | 5193.4555 | 358 | 0 |
|
|
162
|
+
|
|
163
|
+
Overall bounding box: 28 × 28 × 51 (min -14, -14, 0, max 14, 14, 51)
|
|
164
|
+
Triangles: 466
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`tree.forma`:
|
|
168
|
+
|
|
169
|
+
```hcl
|
|
170
|
+
param trunk_height { type = number default = 30 min = 10 max = 60 }
|
|
171
|
+
param canopy_layers { type = number default = 3 min = 1 max = 6 }
|
|
172
|
+
|
|
173
|
+
local canopy_base = trunk_height * 0.7
|
|
174
|
+
|
|
175
|
+
model "tree" {
|
|
176
|
+
part "trunk" {
|
|
177
|
+
color = "#6b4423"
|
|
178
|
+
cone { radius = 4 top_radius = 2.5 height = var.trunk_height }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
part "canopy" {
|
|
182
|
+
color = "#2f7d32"
|
|
183
|
+
for i in range(var.canopy_layers) {
|
|
184
|
+
translate {
|
|
185
|
+
offset = [0, 0, canopy_base + i * 8]
|
|
186
|
+
cone { radius = 14 - i * 3 top_radius = 0 height = 14 }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The document above is a test in this repo rather than an illustration: `test/server.test.js`
|
|
194
|
+
builds it, checks both parts came out solid, drives `trunk_height` to the top of its declared
|
|
195
|
+
range, and exports it. The wrong guesses are asserted to name their own corrections.
|
|
196
|
+
|
|
197
|
+
## More to ask for
|
|
198
|
+
|
|
199
|
+
> Model a wall bracket that carries a 35 mm pipe 60 mm off the wall. Two M5 screw holes in
|
|
200
|
+
> the backplate, 50 mm apart. Make the pipe diameter and the standoff params, and write it
|
|
201
|
+
> to `bracket.forma`.
|
|
202
|
+
|
|
203
|
+
> Write `enclosure.forma`: a 90 × 60 × 25 mm box with 2 mm walls, open at the top, with a lid
|
|
204
|
+
> that drops in on a 0.3 mm clearance lip. Wall thickness and clearance are params. Give the
|
|
205
|
+
> body and the lid separate parts so I can see them apart.
|
|
206
|
+
|
|
207
|
+
> Read `bracket.forma` and re-check it at both ends of every param's declared range. Anything
|
|
208
|
+
> that solves to nothing or changes the bounding box in a way the document did not intend,
|
|
209
|
+
> tell me about before you change it.
|
|
210
|
+
|
|
211
|
+
> Take `enclosure.forma`, set the wall thickness to 3 mm, and export just the lid to
|
|
212
|
+
> `lid.stl`.
|
|
213
|
+
|
|
214
|
+
Two things are worth asking for by name, because they are what the kernel is there to answer:
|
|
215
|
+
the **bounding box**, to confirm the part is the size you meant, and the **genus**, to confirm
|
|
216
|
+
a part that should be a plain solid has not ended up with a hole through it.
|
|
217
|
+
|
|
218
|
+
## The bundled manual
|
|
219
|
+
|
|
220
|
+
`src/reference/` is a copy of [`apps/docs/content`](../../apps/docs/content/README.md), which
|
|
221
|
+
is a private workspace and cannot be depended on from something that ships to npm. The copy is
|
|
222
|
+
what lets the server answer offline. It is made by `npm run sync:reference -w forma-dsl-mcp`
|
|
223
|
+
and compared byte for byte against the original by the tests, so it cannot drift: edit the
|
|
224
|
+
manual, re-run the sync, and the test passes again.
|
|
75
225
|
|
|
76
226
|
## Running it from source
|
|
77
227
|
|
|
78
228
|
```bash
|
|
79
229
|
npm install
|
|
80
|
-
npm test -w forma-dsl-mcp #
|
|
230
|
+
npm test -w forma-dsl-mcp # including the protocol end to end
|
|
81
231
|
node packages/forma-dsl-mcp/src/bin.js # the server, on stdio
|
|
82
232
|
```
|
|
83
233
|
|
|
84
234
|
Point a client at `node /absolute/path/to/packages/forma-dsl-mcp/src/bin.js` to use the
|
|
85
235
|
working tree rather than the published package.
|
|
86
236
|
|
|
87
|
-
## The bundled manual
|
|
88
|
-
|
|
89
|
-
`src/reference/` is a copy of [`apps/docs/content`](../../apps/docs/content/README.md), which
|
|
90
|
-
is a private workspace and cannot be depended on from something that ships to npm. The copy
|
|
91
|
-
is what lets the server answer offline. It is made by `npm run sync:reference -w
|
|
92
|
-
forma-dsl-mcp` and compared byte for byte against the original by the tests, so it cannot
|
|
93
|
-
drift: edit the manual, re-run the sync, and the test passes again.
|
|
94
|
-
|
|
95
237
|
## License
|
|
96
238
|
|
|
97
239
|
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.2.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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"parametric",
|
|
14
14
|
"stl"
|
|
15
15
|
],
|
|
16
|
-
"homepage": "https://github.
|
|
16
|
+
"homepage": "https://kalpak44.github.io/forma-dsl/",
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
19
|
"url": "git+https://github.com/kalpak44/forma-dsl.git",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"start": "node src/bin.js",
|
|
43
43
|
"test": "node --test",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
44
|
+
"check:package": "node scripts/check-package.mjs",
|
|
45
|
+
"sync:reference": "node scripts/sync-reference.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
49
|
-
"forma-dsl": "^0.
|
|
49
|
+
"forma-dsl": "^0.2.2",
|
|
50
50
|
"zod": "^4.1.13"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/bin.js
CHANGED
|
@@ -5,25 +5,29 @@
|
|
|
5
5
|
* Nothing may be written to stdout but the protocol — a stray `console.log` corrupts the
|
|
6
6
|
* JSON-RPC stream and the client disconnects with an error that names nothing. Everything
|
|
7
7
|
* this file has to say goes to stderr, which clients collect as the server's log.
|
|
8
|
+
*
|
|
9
|
+
* There is nothing to configure here. Where the file tools may read and write is asked of
|
|
10
|
+
* the client over MCP's `roots` capability once it has connected, so the directories are
|
|
11
|
+
* the client's to choose and to change.
|
|
8
12
|
*/
|
|
9
13
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
14
|
|
|
11
|
-
import { Workspace } from './workspace.js';
|
|
12
15
|
import { VERSION, createServer } from './server.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Where the file tools are allowed to read and write.
|
|
16
|
-
*
|
|
17
|
-
* Defaults to the working directory, which is what a client that launches the server inside
|
|
18
|
-
* a project gives it. `FORMA_MCP_ROOT` overrides it.
|
|
19
|
-
*/
|
|
20
|
-
const root = process.env.FORMA_MCP_ROOT ?? process.cwd();
|
|
16
|
+
import { askClientForRoots } from './roots.js';
|
|
21
17
|
|
|
22
18
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
19
|
+
const server = createServer();
|
|
20
|
+
|
|
21
|
+
// Logged for the operator's benefit, not the protocol's: a server writing somewhere
|
|
22
|
+
// unexpected is the failure worth being able to see in the client's log.
|
|
23
|
+
server.server.oninitialized = () => {
|
|
24
|
+
askClientForRoots(server.server)
|
|
25
|
+
.then((roots) => console.error(`forma-dsl-mcp: working in ${roots.join(', ')}`))
|
|
26
|
+
.catch(() => {});
|
|
27
|
+
};
|
|
28
|
+
|
|
25
29
|
await server.connect(new StdioServerTransport());
|
|
26
|
-
console.error(`forma-dsl-mcp ${VERSION} ready
|
|
30
|
+
console.error(`forma-dsl-mcp ${VERSION} ready`);
|
|
27
31
|
} catch (error) {
|
|
28
32
|
console.error(`forma-dsl-mcp failed to start: ${error?.message ?? error}`);
|
|
29
33
|
process.exit(1);
|
package/src/reference/errors.md
CHANGED
|
@@ -135,11 +135,11 @@ Raised while building geometry.
|
|
|
135
135
|
|
|
136
136
|
| Message | Cause |
|
|
137
137
|
| --- | --- |
|
|
138
|
-
| `unknown block "bloop"
|
|
138
|
+
| `unknown block "bloop" — the blocks are …` | Not a builtin and not a declared component. The message lists every block there is, this document's own components first |
|
|
139
139
|
| `"box" does not take a label` | Only [`part`](reference/part.md) takes a meaningful label |
|
|
140
140
|
| `"box" is a shape and cannot contain other blocks` | A shape is a leaf. Usually a missing `}` swallowed the next block |
|
|
141
141
|
| `"translate" needs at least one shape inside it` | An operation with nothing to act on |
|
|
142
|
-
| `box: unknown attribute "colour"
|
|
142
|
+
| `box: unknown attribute "colour" — box reads …` | Every block rejects attributes it did not read, so a typo fails loudly, and names what it would have read |
|
|
143
143
|
| `attribute "size" is set twice` | The same attribute written twice in one block |
|
|
144
144
|
| `box: "size" must be a number or an array of 3 numbers` | Wrong shape of value |
|
|
145
145
|
| `circle: "radius" must be a finite number` | A `NaN` or `Infinity` reached the block |
|
|
@@ -149,13 +149,19 @@ Raised while building geometry.
|
|
|
149
149
|
| `cylinder: give "radius"/"diameter", or both "bottom_radius" and "top_radius"` | One end radius on its own is ambiguous |
|
|
150
150
|
| `offset: "join" must be one of "Square", "Round", "Miter"` | An enum attribute with an unrecognised value |
|
|
151
151
|
|
|
152
|
-
### `box: unknown attribute "colour"`
|
|
152
|
+
### `box: unknown attribute "colour" — box reads "center", "size"`
|
|
153
153
|
|
|
154
154
|
Worth dwelling on, because it is the error that saves the most time. A misspelled attribute
|
|
155
155
|
would otherwise be silent, and the model would render subtly wrong with nothing to point at.
|
|
156
156
|
The one exception is a [`param`](reference/param.md) block, which is metadata and passes
|
|
157
157
|
anything through.
|
|
158
158
|
|
|
159
|
+
Both this and `unknown block` carry the alternatives, because the reader is often a program
|
|
160
|
+
with no copy of this manual: an error that says only what is wrong leaves it guessing at
|
|
161
|
+
spellings. A block that branches on which of two spellings it was given — `radius` against
|
|
162
|
+
`diameter` — reads only one of them, so the list is what that call would accept rather than
|
|
163
|
+
always the whole set.
|
|
164
|
+
|
|
159
165
|
---
|
|
160
166
|
|
|
161
167
|
## Dimensionality
|
package/src/reference.js
CHANGED
|
@@ -68,17 +68,19 @@ const BY_PATH = new Map(PAGES.map((page) => [page.path, page]));
|
|
|
68
68
|
* @returns {Promise<string[]>} Paths relative to the manual's root, sorted.
|
|
69
69
|
*/
|
|
70
70
|
export async function discoverPages() {
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// Subdirectories are independent of each other, so they are walked together rather than
|
|
72
|
+
// one after the next; the sort at the end is what makes the result order-stable regardless.
|
|
73
73
|
const walk = async (directory) => {
|
|
74
|
-
|
|
74
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
75
|
+
const found = await Promise.all(entries.map(async (entry) => {
|
|
75
76
|
const child = join(directory, entry.name);
|
|
76
|
-
if (entry.isDirectory())
|
|
77
|
-
|
|
78
|
-
}
|
|
77
|
+
if (entry.isDirectory()) return walk(child);
|
|
78
|
+
return entry.name.endsWith('.md') ? [relative(ROOT, child)] : [];
|
|
79
|
+
}));
|
|
80
|
+
return found.flat();
|
|
79
81
|
};
|
|
80
|
-
|
|
81
|
-
return
|
|
82
|
+
|
|
83
|
+
return (await walk(ROOT)).sort((a, b) => a.localeCompare(b));
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
/**
|
|
@@ -130,8 +132,13 @@ export async function searchReference(query, limit = 6) {
|
|
|
130
132
|
/** @type {Array<{ score: number, path: string, title: string, heading: string, snippet: string }>} */
|
|
131
133
|
const hits = [];
|
|
132
134
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
// Every page is read on every search, so the reads are independent work and belong
|
|
136
|
+
// together: awaiting them one at a time made a search wait on 28 round trips for nothing.
|
|
137
|
+
const pages = await Promise.all(
|
|
138
|
+
PAGES.map(async (page) => ({ page, markdown: await readFile(join(ROOT, page.path), 'utf8') })),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
for (const { page, markdown } of pages) {
|
|
135
142
|
const lower = markdown.toLowerCase();
|
|
136
143
|
if (!terms.every((term) => lower.includes(term))) continue;
|
|
137
144
|
|
package/src/roots.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the client says the files may go.
|
|
3
|
+
*
|
|
4
|
+
* The server does not get to choose the patch of disk it writes to. The MCP `roots`
|
|
5
|
+
* capability is the client's declaration of which directories it is handing over, and asking
|
|
6
|
+
* for it — rather than reading an environment variable the client cannot see — is the whole
|
|
7
|
+
* of this module. A client that declares nothing gets the directory it launched the server
|
|
8
|
+
* in, which is the one location it did choose.
|
|
9
|
+
*/
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
|
|
14
|
+
import { Workspace } from './workspace.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The part of the low-level server this module needs.
|
|
18
|
+
*
|
|
19
|
+
* Named structurally rather than as the SDK's `Server`, which is deprecated in favour of
|
|
20
|
+
* `McpServer` — but `McpServer` does not carry `listRoots` or `setNotificationHandler`, so the
|
|
21
|
+
* low-level object is genuinely the right one to take. Describing the three members used is
|
|
22
|
+
* both the honest contract and what lets a test hand this a stub.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} RootsClient
|
|
25
|
+
* @property {() => ({ roots?: object } | undefined)} getClientCapabilities What the client
|
|
26
|
+
* declared at initialisation.
|
|
27
|
+
* @property {() => Promise<{ roots?: Array<{ uri?: string }> }>} listRoots Asks it for them.
|
|
28
|
+
* @property {(schema: object, handler: () => unknown) => void} setNotificationHandler
|
|
29
|
+
* Registers interest in a notification it may send.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Asks the client which directories the server may work in.
|
|
34
|
+
*
|
|
35
|
+
* Every failure funnels to the launch directory rather than to an error: a client that does
|
|
36
|
+
* not implement roots is the common case, not a broken one, and a server that refuses to
|
|
37
|
+
* work with such a client is worse than one that writes where it was started.
|
|
38
|
+
*
|
|
39
|
+
* @param {RootsClient} server The low-level server, once a client is connected.
|
|
40
|
+
* @returns {Promise<string[]>} Absolute directories, in the order the client gave them.
|
|
41
|
+
*/
|
|
42
|
+
export async function askClientForRoots(server) {
|
|
43
|
+
if (!server.getClientCapabilities()?.roots) return [process.cwd()];
|
|
44
|
+
|
|
45
|
+
/** @type {{ roots?: Array<{ uri: string }> }} */
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await server.listRoots();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const why = /** @type {Error} */ (error)?.message ?? String(error);
|
|
51
|
+
console.error(`forma-dsl-mcp: the client would not list its roots (${why})`);
|
|
52
|
+
return [process.cwd()];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Roots are URIs, and the spec allows schemes this server cannot write to. Anything that
|
|
56
|
+
// is not a local file is dropped rather than guessed at.
|
|
57
|
+
const directories = (result?.roots ?? [])
|
|
58
|
+
.map((root) => root?.uri)
|
|
59
|
+
.filter((uri) => typeof uri === 'string' && uri.startsWith('file://'))
|
|
60
|
+
.map((uri) => fileURLToPath(uri));
|
|
61
|
+
|
|
62
|
+
return directories.length ? directories : [process.cwd()];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A workspace that takes its roots from the connected client, and re-reads them when the
|
|
67
|
+
* client says they have changed.
|
|
68
|
+
*
|
|
69
|
+
* The roots are asked for lazily, at the first file tool call, because `roots/list` is a
|
|
70
|
+
* request back to the client and there is no client to ask until initialisation is done.
|
|
71
|
+
*
|
|
72
|
+
* @param {RootsClient} server The low-level server the client connects to.
|
|
73
|
+
* @returns {Workspace} The workspace, with nothing resolved yet.
|
|
74
|
+
*/
|
|
75
|
+
export function clientWorkspace(server) {
|
|
76
|
+
const workspace = new Workspace(() => askClientForRoots(server));
|
|
77
|
+
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
|
|
78
|
+
workspace.forget();
|
|
79
|
+
});
|
|
80
|
+
return workspace;
|
|
81
|
+
}
|
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,40 +189,30 @@ 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
|
* Registers every tool on a server.
|
|
223
213
|
*
|
|
224
214
|
* @param {McpServer} server The server.
|
|
225
|
-
* @param {Workspace} workspace The patch of filesystem the
|
|
215
|
+
* @param {Workspace} workspace The patch of filesystem the tools may touch.
|
|
226
216
|
* @returns {void}
|
|
227
217
|
*/
|
|
228
218
|
export function registerTools(server, workspace) {
|
|
@@ -393,48 +383,25 @@ export function registerTools(server, workspace) {
|
|
|
393
383
|
].join('\n'));
|
|
394
384
|
}));
|
|
395
385
|
|
|
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
|
-
}));
|
|
386
|
+
// --- writing and building ----------------------------------------------------------------
|
|
425
387
|
|
|
426
388
|
server.registerTool('forma_write', {
|
|
427
|
-
title: '
|
|
389
|
+
title: 'Check, then write a .forma file',
|
|
428
390
|
description:
|
|
429
|
-
'
|
|
430
|
-
+ '
|
|
431
|
-
+ '
|
|
432
|
-
+ '
|
|
391
|
+
'Builds a .forma document with the real geometry kernel and, if it renders, writes it. '
|
|
392
|
+
+ 'The result is the measurement: a table of every part with its bounding box, volume, '
|
|
393
|
+
+ 'triangle count and genus, or the error with the line, the column and an excerpt with '
|
|
394
|
+
+ 'a caret on it. This is how you find out whether what you wrote is the size you meant, '
|
|
395
|
+
+ 'is watertight, and is not silently empty. A document that does not render is refused '
|
|
396
|
+
+ 'and nothing is written — pass `force: true` to write it anyway. Refuses to replace an '
|
|
397
|
+
+ 'existing file unless `overwrite` is set.',
|
|
433
398
|
inputSchema: {
|
|
434
|
-
path: z.string()
|
|
399
|
+
path: z.string()
|
|
400
|
+
.describe('Where to write, relative to the first directory the client declared as a root. Must end in .forma.'),
|
|
435
401
|
source: z.string().describe('The document.'),
|
|
436
|
-
model: z.string().optional().describe('Which model to
|
|
437
|
-
params: z.record(z.string(), z.any()).optional()
|
|
402
|
+
model: z.string().optional().describe('Which model to build. Defaults to the first one declared.'),
|
|
403
|
+
params: z.record(z.string(), z.any()).optional()
|
|
404
|
+
.describe('Values for the document\'s params, by name. Anything not given uses its default.'),
|
|
438
405
|
overwrite: z.boolean().optional().describe('Replace the file if it already exists. Default false.'),
|
|
439
406
|
force: z.boolean().optional().describe('Write even if the document does not render. Default false.'),
|
|
440
407
|
},
|
|
@@ -452,19 +419,23 @@ export function registerTools(server, workspace) {
|
|
|
452
419
|
|
|
453
420
|
const written = await workspace.write(path, source, { overwrite, extension: '.forma' });
|
|
454
421
|
return say([
|
|
455
|
-
`Wrote \`${written.relative}\`
|
|
422
|
+
`Wrote \`${written.relative}\` under \`${written.root}\` — ${written.bytes} bytes`
|
|
423
|
+
+ `${written.replaced ? ', replacing what was there' : ''}.`,
|
|
456
424
|
'',
|
|
457
425
|
formatReport(report),
|
|
458
426
|
].join('\n'));
|
|
459
427
|
}));
|
|
460
428
|
|
|
461
429
|
server.registerTool('forma_read', {
|
|
462
|
-
title: 'Read a
|
|
463
|
-
description:
|
|
464
|
-
|
|
430
|
+
title: 'Read a document back',
|
|
431
|
+
description:
|
|
432
|
+
'Reads a .forma file the client has given access to and builds it, so you see the source '
|
|
433
|
+
+ 'and its current measurements together. Use it before editing something you did not '
|
|
434
|
+
+ 'write in this session.',
|
|
465
435
|
inputSchema: {
|
|
466
|
-
path: z.string().describe('The file, relative to the
|
|
467
|
-
solve: z.boolean().optional()
|
|
436
|
+
path: z.string().describe('The file, relative to the first directory the client declared as a root.'),
|
|
437
|
+
solve: z.boolean().optional()
|
|
438
|
+
.describe('Run the geometry kernel. Default true; false parses only, which is faster.'),
|
|
468
439
|
},
|
|
469
440
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
470
441
|
}, async ({ path, solve }) => attempt(async () => {
|
|
@@ -473,39 +444,28 @@ export function registerTools(server, workspace) {
|
|
|
473
444
|
return say(['```hcl', source.trimEnd(), '```', '', formatReport(report)].join('\n'));
|
|
474
445
|
}));
|
|
475
446
|
|
|
476
|
-
server.registerTool('forma_list', {
|
|
477
|
-
title: 'List the .forma files in the workspace',
|
|
478
|
-
description: 'Every .forma document under the workspace root, so you can find what is '
|
|
479
|
-
+ 'already here instead of guessing at names.',
|
|
480
|
-
inputSchema: {},
|
|
481
|
-
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
482
|
-
}, async () => attempt(async () => {
|
|
483
|
-
const files = await workspace.list('.forma');
|
|
484
|
-
if (!files.length) return say(`No .forma files under ${workspace.root}.`);
|
|
485
|
-
const plural = files.length > 1 ? 's' : '';
|
|
486
|
-
return say([
|
|
487
|
-
`# ${files.length} document${plural} under ${workspace.root}`,
|
|
488
|
-
'',
|
|
489
|
-
...files.map((file) => `- \`${file}\``),
|
|
490
|
-
].join('\n'));
|
|
491
|
-
}));
|
|
492
|
-
|
|
493
447
|
server.registerTool('forma_export_stl', {
|
|
494
|
-
title: '
|
|
448
|
+
title: 'Render and write a binary STL',
|
|
495
449
|
description:
|
|
496
|
-
'Renders a document and writes one part — or every part unioned — as a binary STL
|
|
497
|
-
+ '
|
|
498
|
-
+ '
|
|
450
|
+
'Renders a document and writes one part — or every part unioned — as a binary STL. '
|
|
451
|
+
+ 'Lengths are unitless in forma and STL has no unit either; every consumer treats them '
|
|
452
|
+
+ 'as millimetres.',
|
|
499
453
|
inputSchema: {
|
|
500
|
-
|
|
501
|
-
|
|
454
|
+
source: z.string().optional().describe('The .forma document itself.'),
|
|
455
|
+
path: z.string().optional()
|
|
456
|
+
.describe('A .forma file the client has given access to, instead of `source`.'),
|
|
457
|
+
model: z.string().optional().describe('Which model to build. Defaults to the first one declared.'),
|
|
458
|
+
params: z.record(z.string(), z.any()).optional()
|
|
459
|
+
.describe('Values for the document\'s params, by name. Anything not given uses its default.'),
|
|
460
|
+
out: z.string()
|
|
461
|
+
.describe('Where to write, relative to the first directory the client declared as a root. Must end in .stl.'),
|
|
502
462
|
part: z.string().optional()
|
|
503
463
|
.describe('One part by name. Omitted, a single-part model exports that part and a multi-part model exports them unioned.'),
|
|
504
464
|
overwrite: z.boolean().optional().describe('Replace the file if it already exists. Default false.'),
|
|
505
465
|
},
|
|
506
466
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
507
467
|
}, async (args) => attempt(async () => {
|
|
508
|
-
const
|
|
468
|
+
const source = await documentFrom(workspace, args);
|
|
509
469
|
const result = await exportStl(source, {
|
|
510
470
|
model: args.model ?? null,
|
|
511
471
|
params: args.params ?? {},
|
|
@@ -516,8 +476,8 @@ export function registerTools(server, workspace) {
|
|
|
516
476
|
extension: '.stl',
|
|
517
477
|
});
|
|
518
478
|
return say(
|
|
519
|
-
`Wrote \`${written.relative}\`
|
|
520
|
-
+
|
|
479
|
+
`Wrote \`${written.relative}\` under \`${written.root}\` — ${written.bytes} bytes, `
|
|
480
|
+
+ `${result.triangles} triangles, from model \`${result.model}\`, part \`${result.part}\`.`,
|
|
521
481
|
);
|
|
522
482
|
}));
|
|
523
483
|
}
|
package/src/workspace.js
CHANGED
|
@@ -2,19 +2,25 @@
|
|
|
2
2
|
* The patch of filesystem the server is allowed to touch.
|
|
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
|
-
* security boundary rather than a convenience.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* security boundary rather than a convenience. Which directories make up that set is the
|
|
6
|
+
* client's decision — it declares them as MCP roots, and {@link Workspace} only asks. What
|
|
7
|
+
* the workspace enforces is that everything goes through `resolve`, which refuses anything
|
|
8
|
+
* landing outside all of them, including by way of `..`, an absolute path, or a symlink
|
|
9
|
+
* pointing out of the tree.
|
|
8
10
|
*/
|
|
9
|
-
import { mkdir,
|
|
11
|
+
import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
10
12
|
import { existsSync } from 'node:fs';
|
|
11
13
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
12
14
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* What one path segment inside a root may be.
|
|
17
|
+
*
|
|
18
|
+
* Nothing that separates, escapes or terminates: no slash either way, no NUL, and `..`
|
|
19
|
+
* is rejected alongside it. Every path handed to the filesystem is rebuilt from a root and
|
|
20
|
+
* segments that passed this, so the value that reaches `readFile` is constructed here rather
|
|
21
|
+
* than carried in from the caller.
|
|
22
|
+
*/
|
|
23
|
+
const SAFE_SEGMENT = /^[^\0/\\]+$/;
|
|
18
24
|
|
|
19
25
|
/** Raised for a path the workspace refuses, so a handler can report it as the caller's fault. */
|
|
20
26
|
export class WorkspaceError extends Error {
|
|
@@ -29,101 +35,231 @@ export class WorkspaceError extends Error {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
|
-
*
|
|
38
|
+
* @typedef {object} Root
|
|
39
|
+
* @property {string} path The directory as the client named it, made absolute.
|
|
40
|
+
* @property {string} realPath The same directory with symlinks followed, which containment
|
|
41
|
+
* is tested against.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A view of the filesystem confined to the directories the client declared.
|
|
33
46
|
*
|
|
34
|
-
* The
|
|
35
|
-
*
|
|
47
|
+
* The directories are asked for lazily and then cached, because the client cannot be asked
|
|
48
|
+
* until it has finished connecting, and because asking on every call would put a round trip
|
|
49
|
+
* in front of every file read. `forget` drops the cache when the client says they changed.
|
|
36
50
|
*/
|
|
37
51
|
export class Workspace {
|
|
52
|
+
/** @type {() => Promise<string[]> | string[]} Where the directories come from. */
|
|
53
|
+
#supply;
|
|
54
|
+
|
|
55
|
+
/** @type {Root[] | null} The resolved roots, once they have been asked for. */
|
|
56
|
+
#roots = null;
|
|
57
|
+
|
|
38
58
|
/**
|
|
39
|
-
* @param {
|
|
59
|
+
* @param {(() => Promise<string[]> | string[]) | string[] | string} supply The directories
|
|
60
|
+
* to confine to, or something that produces them when asked.
|
|
40
61
|
*/
|
|
41
|
-
constructor(
|
|
42
|
-
|
|
43
|
-
this.root = resolve(root);
|
|
44
|
-
/** @type {string} The root with symlinks resolved; the one containment is tested against. */
|
|
45
|
-
this.realRoot = this.root;
|
|
62
|
+
constructor(supply) {
|
|
63
|
+
this.#supply = typeof supply === 'function' ? supply : () => supply;
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
/**
|
|
49
|
-
*
|
|
67
|
+
* A workspace confined to one directory, checked now rather than at first use.
|
|
50
68
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
69
|
+
* Useful for embedding the server in something that already knows where the files live,
|
|
70
|
+
* and for tests, which want a bad root to fail where it is written.
|
|
53
71
|
*
|
|
54
72
|
* @param {string} root The directory to root the workspace at.
|
|
55
|
-
* @returns {Promise<Workspace>} The workspace,
|
|
73
|
+
* @returns {Promise<Workspace>} The workspace, with its root already resolved.
|
|
56
74
|
* @throws {WorkspaceError} If the root does not exist or is not a directory.
|
|
57
75
|
*/
|
|
58
76
|
static async open(root) {
|
|
59
|
-
const workspace = new Workspace(root);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
const workspace = new Workspace([root]);
|
|
78
|
+
await workspace.roots();
|
|
79
|
+
return workspace;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The directories in force, resolved through symlinks.
|
|
84
|
+
*
|
|
85
|
+
* A root is resolved once and remembered, so a root that is itself a link still compares
|
|
86
|
+
* equal to the paths resolved beneath it. Roots that do not exist are dropped rather than
|
|
87
|
+
* fatal — a client may well declare a directory this server has no business in — and only
|
|
88
|
+
* an empty result is an error.
|
|
89
|
+
*
|
|
90
|
+
* @returns {Promise<Root[]>} The roots, in the order the client gave them.
|
|
91
|
+
* @throws {WorkspaceError} If not one of them is a usable directory.
|
|
92
|
+
*/
|
|
93
|
+
async roots() {
|
|
94
|
+
if (this.#roots) return this.#roots;
|
|
95
|
+
|
|
96
|
+
const given = await this.#supply();
|
|
97
|
+
// Deduplicated before anything touches the disk, so a client that declares the same
|
|
98
|
+
// directory twice is one stat rather than two.
|
|
99
|
+
const wanted = [...new Set((Array.isArray(given) ? given : [given])
|
|
100
|
+
.filter((each) => typeof each === 'string' && each.trim() !== '')
|
|
101
|
+
.map((each) => resolve(each)))];
|
|
102
|
+
|
|
103
|
+
const examined = await Promise.all(wanted.map(async (path) => {
|
|
104
|
+
try {
|
|
105
|
+
const realPath = await realpath(path);
|
|
106
|
+
if (!(await stat(realPath)).isDirectory()) {
|
|
107
|
+
return { path, why: `${path} is not a directory` };
|
|
108
|
+
}
|
|
109
|
+
return { path, realPath };
|
|
110
|
+
} catch {
|
|
111
|
+
return { path, why: `${path} does not exist` };
|
|
112
|
+
}
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
/** @type {Root[]} */
|
|
116
|
+
const roots = [];
|
|
117
|
+
/** @type {string[]} */
|
|
118
|
+
const refused = [];
|
|
119
|
+
for (const entry of examined) {
|
|
120
|
+
if (entry.why) refused.push(entry.why);
|
|
121
|
+
else roots.push({ path: entry.path, realPath: /** @type {string} */ (entry.realPath) });
|
|
66
122
|
}
|
|
67
|
-
|
|
68
|
-
|
|
123
|
+
|
|
124
|
+
if (!roots.length) {
|
|
125
|
+
throw new WorkspaceError(
|
|
126
|
+
refused.length
|
|
127
|
+
? `no usable workspace root — ${refused.join('; ')}`
|
|
128
|
+
: 'the client has declared no workspace root, so there is nowhere to read or write; '
|
|
129
|
+
+ 'declare one with the MCP roots capability',
|
|
130
|
+
);
|
|
69
131
|
}
|
|
70
|
-
|
|
132
|
+
|
|
133
|
+
this.#roots = roots;
|
|
134
|
+
return roots;
|
|
71
135
|
}
|
|
72
136
|
|
|
73
137
|
/**
|
|
74
|
-
*
|
|
138
|
+
* Drops the resolved roots, so the next call asks for them again.
|
|
139
|
+
*
|
|
140
|
+
* Called when the client sends `notifications/roots/list_changed`, which is the only way
|
|
141
|
+
* the set is allowed to move.
|
|
142
|
+
*
|
|
143
|
+
* @returns {void}
|
|
144
|
+
*/
|
|
145
|
+
forget() {
|
|
146
|
+
this.#roots = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The root a relative path is taken against, which is the first the client declared.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Promise<Root>} The primary root.
|
|
153
|
+
* @throws {WorkspaceError} If there is no usable root.
|
|
154
|
+
*/
|
|
155
|
+
async primary() {
|
|
156
|
+
const [first] = await this.roots();
|
|
157
|
+
return first;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Turns a caller's path into an absolute one inside a root.
|
|
75
162
|
*
|
|
76
163
|
* Containment is checked twice: once on the lexical path, and again on the nearest
|
|
77
164
|
* existing ancestor with symlinks followed. The first catches `../../etc/passwd`; the
|
|
78
|
-
* second catches a directory inside
|
|
165
|
+
* second catches a directory inside a root that is a link to somewhere outside it.
|
|
79
166
|
*
|
|
80
|
-
* @param {string} path A path relative to the root, or an absolute one inside
|
|
167
|
+
* @param {string} path A path relative to the primary root, or an absolute one inside any
|
|
168
|
+
* of them.
|
|
81
169
|
* @param {string} [what] What the path is for, used in the error message.
|
|
82
|
-
* @returns {Promise<string>} The
|
|
83
|
-
* @throws {WorkspaceError} If the path escapes
|
|
170
|
+
* @returns {Promise<{ absolute: string, root: Root }>} The path, and the root holding it.
|
|
171
|
+
* @throws {WorkspaceError} If the path escapes every root.
|
|
84
172
|
*/
|
|
85
173
|
async resolve(path, what = 'path') {
|
|
86
174
|
if (typeof path !== 'string' || path.trim() === '') {
|
|
87
175
|
throw new WorkspaceError(`the ${what} is empty`);
|
|
88
176
|
}
|
|
89
177
|
|
|
90
|
-
const
|
|
91
|
-
|
|
178
|
+
const roots = await this.roots();
|
|
179
|
+
const lexical = isAbsolute(path) ? resolve(path) : resolve(roots[0].path, path);
|
|
180
|
+
|
|
181
|
+
const root = this.#holderOf(roots, lexical);
|
|
182
|
+
if (!root) throw this.#outside(roots, path, what);
|
|
183
|
+
|
|
184
|
+
// Rebuilt before anything touches the disk, which is the order that matters: the string
|
|
185
|
+
// the caller gave is used to choose segments and never to name a file. Each segment has
|
|
186
|
+
// to match SAFE_SEGMENT, and what comes out is assembled onto a directory the client
|
|
187
|
+
// declared. Every line below, and every caller, works with that value rather than the one
|
|
188
|
+
// that came in — so the walk beneath cannot be steered by a name this never approved.
|
|
189
|
+
const base = this.#baseOf(root, lexical);
|
|
190
|
+
const segments = relative(base, lexical).split(sep).filter(Boolean);
|
|
191
|
+
if (!segments.every((segment) => SAFE_SEGMENT.test(segment))) {
|
|
192
|
+
throw this.#outside(roots, path, what);
|
|
193
|
+
}
|
|
194
|
+
const absolute = segments.reduce((at, segment) => join(at, segment), base);
|
|
92
195
|
|
|
93
196
|
// The file itself may not exist yet — a write is the common case — so the deepest
|
|
94
|
-
// ancestor that does exist is what gets its links followed.
|
|
197
|
+
// ancestor that does exist is what gets its links followed. This is the one check that
|
|
198
|
+
// cannot be made without the filesystem: whether a directory inside a root is a link out
|
|
199
|
+
// of it is not answerable from the path alone.
|
|
95
200
|
let existing = absolute;
|
|
96
201
|
while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
97
202
|
const real = await realpath(existing);
|
|
98
|
-
this.#
|
|
203
|
+
if (!this.#holderOf(roots, join(real, relative(existing, absolute)))) {
|
|
204
|
+
throw this.#outside(roots, path, what);
|
|
205
|
+
}
|
|
99
206
|
|
|
100
|
-
return absolute;
|
|
207
|
+
return { absolute, root };
|
|
101
208
|
}
|
|
102
209
|
|
|
103
210
|
/**
|
|
211
|
+
* Which spelling of a root a path sits under — the one the client gave, or the one symlinks
|
|
212
|
+
* resolve it to.
|
|
213
|
+
*
|
|
214
|
+
* @param {Root} root The root holding it.
|
|
215
|
+
* @param {string} absolute The path.
|
|
216
|
+
* @returns {string} The prefix to measure against.
|
|
217
|
+
*/
|
|
218
|
+
#baseOf(root, absolute) {
|
|
219
|
+
return absolute === root.path || absolute.startsWith(root.path + sep) ? root.path : root.realPath;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {Root[]} roots The roots to test against.
|
|
104
224
|
* @param {string} candidate An absolute path.
|
|
225
|
+
* @returns {Root | null} The root that holds it, or null if none does.
|
|
226
|
+
*/
|
|
227
|
+
#holderOf(roots, candidate) {
|
|
228
|
+
return roots.find(({ path, realPath }) => [path, realPath].some(
|
|
229
|
+
(root) => candidate === root || candidate.startsWith(root + sep),
|
|
230
|
+
)) ?? null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {Root[]} roots The roots the path missed.
|
|
105
235
|
* @param {string} original The path as the caller wrote it, for the message.
|
|
106
236
|
* @param {string} what What the path is for.
|
|
107
|
-
* @returns {
|
|
108
|
-
* @throws {WorkspaceError} If the candidate is neither the root nor beneath it.
|
|
237
|
+
* @returns {WorkspaceError} The refusal, ready to throw.
|
|
109
238
|
*/
|
|
110
|
-
#
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
239
|
+
#outside(roots, original, what) {
|
|
240
|
+
const named = roots.map((root) => root.path).join(', ');
|
|
241
|
+
const noun = roots.length > 1 ? 'roots' : 'root';
|
|
242
|
+
return new WorkspaceError(
|
|
243
|
+
`the ${what} "${original}" is outside the workspace ${noun} ${named}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @param {Root} root The root the path was resolved against.
|
|
249
|
+
* @param {string} absolute The path.
|
|
250
|
+
* @returns {string} The path within the root.
|
|
251
|
+
*/
|
|
252
|
+
#within(root, absolute) {
|
|
253
|
+
return relative(this.#baseOf(root, absolute), absolute) || '.';
|
|
118
254
|
}
|
|
119
255
|
|
|
120
256
|
/**
|
|
121
257
|
* @param {string} path The file to read.
|
|
122
258
|
* @returns {Promise<string>} Its contents, as UTF-8.
|
|
123
|
-
* @throws {WorkspaceError} If the path escapes the
|
|
259
|
+
* @throws {WorkspaceError} If the path escapes the roots or names no file.
|
|
124
260
|
*/
|
|
125
261
|
async read(path) {
|
|
126
|
-
const absolute = await this.resolve(path, 'path');
|
|
262
|
+
const { absolute } = await this.resolve(path, 'path');
|
|
127
263
|
try {
|
|
128
264
|
return await readFile(absolute, 'utf8');
|
|
129
265
|
} catch {
|
|
@@ -143,14 +279,14 @@ export class Workspace {
|
|
|
143
279
|
* @param {object} [options] How to write it.
|
|
144
280
|
* @param {boolean} [options.overwrite] Whether replacing an existing file is allowed.
|
|
145
281
|
* @param {string} [options.extension] An extension the path must have.
|
|
146
|
-
* @returns {Promise<{ path: string, relative: string, bytes: number, replaced: boolean }>}
|
|
282
|
+
* @returns {Promise<{ path: string, relative: string, root: string, bytes: number, replaced: boolean }>}
|
|
147
283
|
* Where it went and how big it was.
|
|
148
|
-
* @throws {WorkspaceError} If the path escapes the
|
|
284
|
+
* @throws {WorkspaceError} If the path escapes the roots, has the wrong extension, or
|
|
149
285
|
* already exists and `overwrite` was not set.
|
|
150
286
|
*/
|
|
151
287
|
async write(path, contents, options = {}) {
|
|
152
288
|
const { overwrite = false, extension } = options;
|
|
153
|
-
const absolute = await this.resolve(path, 'path');
|
|
289
|
+
const { absolute, root } = await this.resolve(path, 'path');
|
|
154
290
|
|
|
155
291
|
if (extension && extname(absolute).toLowerCase() !== extension) {
|
|
156
292
|
throw new WorkspaceError(`"${path}" must end in ${extension}`);
|
|
@@ -165,40 +301,6 @@ export class Workspace {
|
|
|
165
301
|
await writeFile(absolute, contents);
|
|
166
302
|
|
|
167
303
|
const bytes = typeof contents === 'string' ? Buffer.byteLength(contents) : contents.length;
|
|
168
|
-
return { path: absolute, relative:
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Finds files by extension, so a caller can discover what is already here rather than
|
|
173
|
-
* guessing at names.
|
|
174
|
-
*
|
|
175
|
-
* @param {string} [extension] The extension to match, including the dot.
|
|
176
|
-
* @returns {Promise<string[]>} Paths relative to the root, sorted, depth-first.
|
|
177
|
-
*/
|
|
178
|
-
async list(extension = '.forma') {
|
|
179
|
-
/** @type {string[]} */
|
|
180
|
-
const found = [];
|
|
181
|
-
|
|
182
|
-
const walk = async (directory, depth) => {
|
|
183
|
-
if (depth > MAX_DEPTH) return;
|
|
184
|
-
/** @type {import('node:fs').Dirent[]} */
|
|
185
|
-
let entries;
|
|
186
|
-
try {
|
|
187
|
-
entries = await readdir(directory, { withFileTypes: true });
|
|
188
|
-
} catch {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
for (const entry of entries) {
|
|
192
|
-
if (entry.name.startsWith('.') || SKIPPED.has(entry.name)) continue;
|
|
193
|
-
const child = join(directory, entry.name);
|
|
194
|
-
if (entry.isDirectory()) await walk(child, depth + 1);
|
|
195
|
-
else if (entry.isFile() && extname(entry.name).toLowerCase() === extension) {
|
|
196
|
-
found.push(relative(this.realRoot, child));
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
await walk(this.realRoot, 0);
|
|
202
|
-
return found.sort((a, b) => a.localeCompare(b));
|
|
304
|
+
return { path: absolute, relative: this.#within(root, absolute), root: root.path, bytes, replaced };
|
|
203
305
|
}
|
|
204
306
|
}
|