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/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
|
}
|