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 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
- writes `.forma` documents that render.
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 speaks over stdio, so any MCP client can launch 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
- `FORMA_MCP_ROOT` is the only configuration. It defaults to the working directory the client
25
- launches the server in, and **every path the file tools accept is confined to it** — an
26
- absolute path, a `..` that climbs out, or a symlink pointing out of the tree is refused
27
- rather than followed.
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**. `forma_check`
33
- compiles the document, solves it with [Manifold](https://github.com/elalish/manifold) and
34
- reports back the bounding box, the volume, the triangle count and the genus of every part —
35
- so the question "is this the size that was asked for, and is it actually a solid?" is
36
- answered before anyone is told the work is done. A part that silently solved to nothing is
37
- called out by name.
38
-
39
- `forma_write` runs that same check and **refuses to write a document that does not render**.
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 that the two agree: a block added, renamed or given a new dimensionality in
44
- `forma-dsl` fails this package's build rather than becoming a confident wrong answer. Every
45
- worked example is rendered by the test suite, at both ends of every parameter's declared
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
- | `forma_check` | Parse, build and **measure** — errors with a line, a column and an excerpt with a caret; parts with their boxes, volumes and triangle counts |
58
- | `forma_write` | Check, then write a `.forma` file into the workspace |
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 and prompts
118
+ ## Resources
64
119
 
65
- Resources carry the same knowledge in a form a person can attach by hand:
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
- Two prompts name the workflows worth having a name for: `model_a_part`, which walks from a
73
- description of a physical object to a checked document, and `review_a_document`, which reads
74
- an existing one and looks for the failures that are silent.
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 # 140+ tests, including the protocol end to end
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.6",
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.com/kalpak44/forma-dsl/tree/main/packages/forma-dsl-mcp#readme",
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
- "sync:reference": "node scripts/sync-reference.mjs",
45
- "check:package": "node scripts/check-package.mjs"
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.6",
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 workspace = await Workspace.open(root);
24
- const server = createServer(workspace);
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 — workspace ${workspace.root}`);
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;
@@ -135,11 +135,11 @@ Raised while building geometry.
135
135
 
136
136
  | Message | Cause |
137
137
  | --- | --- |
138
- | `unknown block "bloop"` | Not a builtin and not a declared component. Check the spelling against the [block list](README.md#shapes) |
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"` | Every block rejects attributes it did not read, so a typo fails loudly |
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
- /** @type {string[]} */
72
- const found = [];
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
- for (const entry of await readdir(directory, { withFileTypes: true })) {
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()) await walk(child);
77
- else if (entry.name.endsWith('.md')) found.push(relative(ROOT, child));
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
- await walk(ROOT);
81
- return found.sort((a, b) => a.localeCompare(b));
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
+ }