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/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.1
|
|
3
|
+
"version": "0.3.1",
|
|
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.1
|
|
49
|
+
"forma-dsl": "^0.3.1",
|
|
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/document.js
CHANGED
|
@@ -20,6 +20,19 @@ import {
|
|
|
20
20
|
/** How many lines of source to show either side of an error. */
|
|
21
21
|
const CONTEXT_LINES = 2;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How much geometry one document may build before the server refuses it.
|
|
25
|
+
*
|
|
26
|
+
* This server exists to build text a model wrote, which is the case the library leaves a
|
|
27
|
+
* budget off for. Nesting limits and `range` each bound one loop; only this bounds two of
|
|
28
|
+
* them nested, and that shape asks for more geometry than the process can finish — on a pipe,
|
|
29
|
+
* with no request timeout of its own, that is a server that never answers again.
|
|
30
|
+
*
|
|
31
|
+
* Set far above any honest document: the worked examples build tens of blocks. A model that
|
|
32
|
+
* reaches this has written a loop it did not mean to.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_NODES = 50_000;
|
|
35
|
+
|
|
23
36
|
/**
|
|
24
37
|
* The context reused across calls, so an edit-and-recheck loop pays only for the subtrees
|
|
25
38
|
* that changed.
|
|
@@ -195,7 +208,7 @@ export async function checkDocument(source, options = {}) {
|
|
|
195
208
|
/** @type {RenderResult} */
|
|
196
209
|
let result;
|
|
197
210
|
try {
|
|
198
|
-
result = await render(source, { model, params, context: shared });
|
|
211
|
+
result = await render(source, { model, params, context: shared, maxNodes: MAX_NODES });
|
|
199
212
|
} catch (error) {
|
|
200
213
|
return { ok: false, stage: 'render', ...declared, error: diagnose('render', error, source) };
|
|
201
214
|
}
|
|
@@ -255,7 +268,7 @@ export async function exportStl(source, options = {}) {
|
|
|
255
268
|
const { model = null, params = {}, part = null } = options;
|
|
256
269
|
|
|
257
270
|
shared ??= await EvaluationContext.create();
|
|
258
|
-
const result = await render(source, { model, params, context: shared });
|
|
271
|
+
const result = await render(source, { model, params, context: shared, maxNodes: MAX_NODES });
|
|
259
272
|
|
|
260
273
|
let solid;
|
|
261
274
|
let name;
|
package/src/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
|
/**
|
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
|
+
}
|