forma-dsl-mcp 0.1.4

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +52 -0
  4. package/src/bin.js +30 -0
  5. package/src/catalogue.js +703 -0
  6. package/src/document.js +285 -0
  7. package/src/examples.js +212 -0
  8. package/src/guide.js +313 -0
  9. package/src/reference/README.md +153 -0
  10. package/src/reference/api/describe-parameters.md +167 -0
  11. package/src/reference/api/evaluation-context.md +221 -0
  12. package/src/reference/api/export.md +185 -0
  13. package/src/reference/api/geometry-node.md +215 -0
  14. package/src/reference/api/kernel.md +182 -0
  15. package/src/reference/api/low-level.md +239 -0
  16. package/src/reference/api/render.md +217 -0
  17. package/src/reference/api/values.md +223 -0
  18. package/src/reference/errors.md +253 -0
  19. package/src/reference/language/control-flow.md +160 -0
  20. package/src/reference/language/expressions.md +206 -0
  21. package/src/reference/language/overview.md +156 -0
  22. package/src/reference/language/scope.md +184 -0
  23. package/src/reference/language/syntax.md +210 -0
  24. package/src/reference/reference/align.md +189 -0
  25. package/src/reference/reference/booleans.md +231 -0
  26. package/src/reference/reference/component.md +248 -0
  27. package/src/reference/reference/conversions.md +269 -0
  28. package/src/reference/reference/functions.md +314 -0
  29. package/src/reference/reference/local.md +197 -0
  30. package/src/reference/reference/model.md +213 -0
  31. package/src/reference/reference/param.md +230 -0
  32. package/src/reference/reference/part.md +229 -0
  33. package/src/reference/reference/refinement.md +284 -0
  34. package/src/reference/reference/shapes-2d.md +309 -0
  35. package/src/reference/reference/shapes-3d.md +279 -0
  36. package/src/reference/reference/transforms.md +246 -0
  37. package/src/reference.js +160 -0
  38. package/src/server.js +219 -0
  39. package/src/tools.js +523 -0
  40. package/src/workspace.js +204 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The patch of filesystem the server is allowed to touch.
3
+ *
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.
8
+ */
9
+ import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
+
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']);
18
+
19
+ /** Raised for a path the workspace refuses, so a handler can report it as the caller's fault. */
20
+ export class WorkspaceError extends Error {
21
+ /**
22
+ * @param {string} message What was refused, and why.
23
+ */
24
+ constructor(message) {
25
+ super(message);
26
+ /** @type {string} Distinguishes a refusal from a genuine I/O failure. */
27
+ this.name = 'WorkspaceError';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * A rooted view of the filesystem.
33
+ *
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.
36
+ */
37
+ export class Workspace {
38
+ /**
39
+ * @param {string} root The directory every path is resolved against and confined to.
40
+ */
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;
46
+ }
47
+
48
+ /**
49
+ * Resolves the root through symlinks.
50
+ *
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.
53
+ *
54
+ * @param {string} root The directory to root the workspace at.
55
+ * @returns {Promise<Workspace>} The workspace, ready to use.
56
+ * @throws {WorkspaceError} If the root does not exist or is not a directory.
57
+ */
58
+ 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`);
66
+ }
67
+ if (!info.isDirectory()) {
68
+ throw new WorkspaceError(`the workspace root ${workspace.root} is not a directory`);
69
+ }
70
+ return workspace;
71
+ }
72
+
73
+ /**
74
+ * Turns a caller's path into an absolute one inside the root.
75
+ *
76
+ * Containment is checked twice: once on the lexical path, and again on the nearest
77
+ * 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.
79
+ *
80
+ * @param {string} path A path relative to the root, or an absolute one inside it.
81
+ * @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.
84
+ */
85
+ async resolve(path, what = 'path') {
86
+ if (typeof path !== 'string' || path.trim() === '') {
87
+ throw new WorkspaceError(`the ${what} is empty`);
88
+ }
89
+
90
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(this.root, path);
91
+ this.#assertInside(absolute, path, what);
92
+
93
+ // 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.
95
+ let existing = absolute;
96
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
97
+ const real = await realpath(existing);
98
+ this.#assertInside(join(real, relative(existing, absolute)), path, what);
99
+
100
+ return absolute;
101
+ }
102
+
103
+ /**
104
+ * @param {string} candidate An absolute path.
105
+ * @param {string} original The path as the caller wrote it, for the message.
106
+ * @param {string} what What the path is for.
107
+ * @returns {void}
108
+ * @throws {WorkspaceError} If the candidate is neither the root nor beneath it.
109
+ */
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
+ }
118
+ }
119
+
120
+ /**
121
+ * @param {string} path The file to read.
122
+ * @returns {Promise<string>} Its contents, as UTF-8.
123
+ * @throws {WorkspaceError} If the path escapes the root or names no file.
124
+ */
125
+ async read(path) {
126
+ const absolute = await this.resolve(path, 'path');
127
+ try {
128
+ return await readFile(absolute, 'utf8');
129
+ } catch {
130
+ throw new WorkspaceError(`no file at "${path}"`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Writes a file, creating the directories above it.
136
+ *
137
+ * Refuses to replace an existing file unless told to: a model that regenerates a document
138
+ * it did not mean to touch has no undo, and the client's own diff view is not in the loop
139
+ * for a tool call.
140
+ *
141
+ * @param {string} path Where to write.
142
+ * @param {string | Uint8Array} contents What to write.
143
+ * @param {object} [options] How to write it.
144
+ * @param {boolean} [options.overwrite] Whether replacing an existing file is allowed.
145
+ * @param {string} [options.extension] An extension the path must have.
146
+ * @returns {Promise<{ path: string, relative: string, bytes: number, replaced: boolean }>}
147
+ * Where it went and how big it was.
148
+ * @throws {WorkspaceError} If the path escapes the root, has the wrong extension, or
149
+ * already exists and `overwrite` was not set.
150
+ */
151
+ async write(path, contents, options = {}) {
152
+ const { overwrite = false, extension } = options;
153
+ const absolute = await this.resolve(path, 'path');
154
+
155
+ if (extension && extname(absolute).toLowerCase() !== extension) {
156
+ throw new WorkspaceError(`"${path}" must end in ${extension}`);
157
+ }
158
+
159
+ const replaced = existsSync(absolute);
160
+ if (replaced && !overwrite) {
161
+ throw new WorkspaceError(`"${path}" already exists — pass overwrite: true to replace it`);
162
+ }
163
+
164
+ await mkdir(dirname(absolute), { recursive: true });
165
+ await writeFile(absolute, contents);
166
+
167
+ 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));
203
+ }
204
+ }