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/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. Everything goes through `resolve`, which
6
- * refuses anything that lands outside the root — including by way of `..`, an absolute path,
7
- * or a symlink pointing out of the tree.
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, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
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
- /** How deep `list` will descend before it stops looking. */
14
- const MAX_DEPTH = 8;
15
-
16
- /** Directories that are never worth walking, and are large enough to be worth skipping. */
17
- const SKIPPED = new Set(['node_modules', '.git', 'dist', 'coverage', '.cache']);
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
- * A rooted view of the filesystem.
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 root is resolved through symlinks once, at construction, so a root that is itself a
35
- * link still compares equal to the paths resolved beneath it.
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 {string} root The directory every path is resolved against and confined to.
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(root) {
42
- /** @type {string} The absolute root, before symlinks are followed. */
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
- * Resolves the root through symlinks.
67
+ * A workspace confined to one directory, checked now rather than at first use.
50
68
  *
51
- * Separate from the constructor because it touches the disk, and a constructor that can
52
- * fail on I/O is a constructor every caller has to wrap.
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, ready to use.
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
- let info;
61
- try {
62
- workspace.realRoot = await realpath(workspace.root);
63
- info = await stat(workspace.realRoot);
64
- } catch {
65
- throw new WorkspaceError(`the workspace root ${workspace.root} does not exist`);
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
- if (!info.isDirectory()) {
68
- throw new WorkspaceError(`the workspace root ${workspace.root} is not a directory`);
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
- return workspace;
132
+
133
+ this.#roots = roots;
134
+ return roots;
71
135
  }
72
136
 
73
137
  /**
74
- * Turns a caller's path into an absolute one inside the root.
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 the root that is a link to somewhere outside it.
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 it.
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 absolute path.
83
- * @throws {WorkspaceError} If the path escapes the root.
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 absolute = isAbsolute(path) ? resolve(path) : resolve(this.root, path);
91
- this.#assertInside(absolute, path, what);
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.#assertInside(join(real, relative(existing, absolute)), path, what);
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 {void}
108
- * @throws {WorkspaceError} If the candidate is neither the root nor beneath it.
237
+ * @returns {WorkspaceError} The refusal, ready to throw.
109
238
  */
110
- #assertInside(candidate, original, what) {
111
- const roots = [this.root, this.realRoot];
112
- const inside = roots.some((root) => candidate === root || candidate.startsWith(root + sep));
113
- if (!inside) {
114
- throw new WorkspaceError(
115
- `the ${what} "${original}" is outside the workspace root ${this.root}`,
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 root or names no file.
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 root, has the wrong extension, or
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: relative(this.root, absolute) || '.', bytes, replaced };
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
  }