scanruler-mcp 0.4.1 → 0.5.0

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/tools.js CHANGED
@@ -1,339 +1,379 @@
1
- // SPDX-License-Identifier: AGPL-3.0-only
2
- // The page's commands as MCP tools. A command's name becomes the tool's,
3
- // dots to underscores — element.fit is element_fit — and its JSON Schema
4
- // passes through, but for files: a command that takes a file's bytes takes a
5
- // path on this computer instead, and this server reads the file and streams
6
- // it to the page; a command that hands a file back takes an optional path,
7
- // and this server writes the file there. The agent never sees the bytes —
8
- // but for a picture (a command marked returnsImage), which it is shown as an
9
- // image, and which is written to a file only when it gives a path — or, in
10
- // a batch, where it cannot be shown, always.
11
-
12
- import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
13
- import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
14
-
15
- export const toolName = (command) => command.name.replace(/\./g, '_')
16
-
17
- /** The input property that carries a file's bytes, if the command has one. */
18
- export function bytesProperty(command) {
19
- const props = command.input?.properties ?? {}
20
- return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
21
- }
22
-
23
- const inPath = {
24
- type: 'string',
25
- minLength: 1,
26
- description: 'The file to read, on this computer: an absolute path, or one relative to the folder the agent works in.',
27
- }
28
-
29
- const outPath = (outDir) => ({
30
- type: 'string',
31
- minLength: 1,
32
- description: `Where to write the file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it goes into ${outDir}.`,
33
- })
34
-
35
- const imagePath = (outDir) => ({
36
- type: 'string',
37
- minLength: 1,
38
- description: `Also write the picture to a file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written into ${outDir} under a new name.`,
39
- })
40
-
41
- /** The `saveTo` a command that replaces the session takes — one with
42
- * `discard`: the session saved as a project file first (mcp.js). */
43
- const saveToIn = {
44
- type: 'string',
45
- minLength: 1,
46
- description: 'Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo).',
47
- }
48
-
49
- /** Whether a command replaces the session, refusing to over work unless
50
- * told `discard` — what `saveTo` is offered on. */
51
- export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
52
-
53
- const inlineIn = {
54
- type: 'boolean',
55
- description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
56
- }
57
-
58
- /** How many significant digits a number keeps in an answer, and the finest
59
- * step it is given to: six digits is a micrometre on a part under a metre
60
- * and a millionth of a unit vector; past them the page's doubles are
61
- * noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
62
- const DIGITS = 6
63
- const STEP = 1e6
64
-
65
- const tidy = (v) => {
66
- const r = Number(v.toPrecision(DIGITS))
67
- return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
68
- }
69
-
70
- /** An answer as the agent reads it: JSON, every number that is not whole
71
- * kept to DIGITS significant digits and no finer than a millionth — 1e-17,
72
- * a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
73
- * are as they were. An `exact` answer is one whose numbers are handed
74
- * back as inputs — a plane's frame, say — and keeps every digit:
75
- * shortened, what is built on them is a hair off what it was meant to
76
- * meet. */
77
- export const agentJson = (value, exact = false) =>
78
- exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
79
-
80
- /** A command as the MCP tool an agent sees. */
81
- /** The `wait` a tool that changes the session takes, and a long read
82
- * (Command.long): the server runs every such call as a job (mcp.js), and
83
- * false answers with the job at once. */
84
- export const waitIn = {
85
- type: 'boolean',
86
- description: 'false: answer at once with { queued: true, job } while the call runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done — or, still running at the server’s answer limit (scanruler_status: server.answerWithin, seconds), with { queued: true, job, after } all the same.',
87
- }
88
-
89
- /** The `timings` a tool that changes the session takes: the server keeps
90
- * what the page's answer carries of the build's steps (mcp.js). */
91
- export const timingsIn = {
92
- type: 'boolean',
93
- description: 'true: the answer adds ms (the call, start to answer) and, where the change built, steps.played — each step the build played with its ms (steps.restored counts those taken up from a kept state, not played).',
94
- }
95
-
96
- export function toolFor(command, { outDir }) {
97
- const input = structuredClone(command.input ?? { type: 'object' })
98
- input.properties = { ...(input.properties ?? {}) }
99
- let required = [...(input.required ?? [])]
100
- const bytesKey = bytesProperty(command)
101
- let description = command.description
102
- if (bytesKey) {
103
- delete input.properties[bytesKey]
104
- const pick = FOLDER_PICKS[command.name]
105
- input.properties.path = pick ? { ...inPath, description: `${inPath.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : inPath
106
- required = required.filter((key) => key !== bytesKey && key !== 'name')
107
- if (!required.includes('path')) required.unshift('path')
108
- if (input.properties.name) {
109
- input.properties.name = { ...input.properties.name, description: `${input.properties.name.description ?? ''} Defaults to the file's own name.`.trim() }
110
- }
111
- description += ' The file is read from `path` on this computer and streamed to the page.'
112
- }
113
- if (command.returnsImage) {
114
- input.properties.path = imagePath(outDir)
115
- input.properties.inline = inlineIn
116
- description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
117
- } else if (command.returnsFile) {
118
- input.properties.path = outPath(outDir)
119
- description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
120
- }
121
- if (replacesSession(command)) {
122
- input.properties.saveTo = saveToIn
123
- description += ' With `saveTo`, the session is saved as a project file there first (as project_save does), then replaced as with discard: true — one call to keep what a page holds and start anew.'
124
- }
125
- if (!command.readOnly) {
126
- input.properties.wait = waitIn
127
- input.properties.timings = timingsIn
128
- } else if (command.long) {
129
- // A long read runs as a job too (mcp.js), so it takes wait.
130
- input.properties.wait = waitIn
131
- }
132
- if (required.length) input.required = required
133
- else delete input.required
134
- return {
135
- name: toolName(command),
136
- title: command.title,
137
- description,
138
- inputSchema: input,
139
- annotations: { title: command.title, readOnlyHint: Boolean(command.readOnly) && (!command.returnsFile || Boolean(command.returnsImage)), openWorldHint: false },
140
- }
141
- }
142
-
143
- /** A path as the agent gave it, made absolute against `base`. */
144
- export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
145
-
146
- /** What a folder given in place of a file opens, by command: the one file
147
- * of its kind in it. Other commands take a file only. */
148
- const FOLDER_PICKS = {
149
- 'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj'] },
150
- 'project.load': { what: 'project', kinds: ['.scanruler'] },
151
- }
152
-
153
- /** The file a path names: itself, or — for a folder, where the command
154
- * takes one (FOLDER_PICKS) — the only file of the command's kind in it.
155
- * Refused with the folder's files listed otherwise. */
156
- async function fileAt(command, file) {
157
- const found = await stat(file).catch(() => null)
158
- if (!found?.isDirectory()) return file
159
- const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
160
- const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
161
- const pick = FOLDER_PICKS[command.name]
162
- if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
163
- const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
164
- if (matches.length === 1) return join(file, matches[0])
165
- if (matches.length === 0) {
166
- throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
167
- }
168
- throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
169
- }
170
-
171
- /** How many near names a missing path's refusal lists at most, and how many
172
- * letters off a near name may be. */
173
- const NEAR_LISTED = 5
174
- const NEAR_LETTERS = 2
175
-
176
- /** A name as nearNames compares it: case ignored, and "-", "_" and a
177
- * space taken alike. */
178
- const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
179
-
180
- /** How many letters `a` and `b` are apart — added, taken out or changed —
181
- * or `most + 1` once past `most`. */
182
- function lettersApart(a, b, most) {
183
- if (Math.abs(a.length - b.length) > most) return most + 1
184
- let row = Array.from({ length: b.length + 1 }, (_, j) => j)
185
- for (let i = 1; i <= a.length; i++) {
186
- const next = [i]
187
- for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
188
- if (Math.min(...next) > most) return most + 1
189
- row = next
190
- }
191
- return row[b.length]
192
- }
193
-
194
- /** Where a path that is not there may have been meant: the first folder up
195
- * from it that is there, the name in the path missing from it, and up to
196
- * NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
197
- * path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
198
- * given with the rest of the path when that is in it. */
199
- export async function nearNames(path) {
200
- let missing = resolve(path)
201
- let folder = dirname(missing)
202
- let rest = []
203
- for (;;) {
204
- if ((await stat(folder).catch(() => null))?.isDirectory()) break
205
- if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
206
- rest = [basename(missing), ...rest]
207
- missing = folder
208
- folder = dirname(folder)
209
- }
210
- const want = nearForm(basename(missing))
211
- const names = await readdir(folder).catch(() => [])
212
- const near = names
213
- .map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
214
- .filter((n) => n.apart <= NEAR_LETTERS)
215
- .sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
216
- .slice(0, NEAR_LISTED)
217
- const paths = []
218
- for (const { name } of near) {
219
- const whole = join(folder, name, ...rest)
220
- paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
221
- }
222
- return { folder, missing: basename(missing), near: paths }
223
- }
224
-
225
- /** nearNames as a refusal's last sentence. */
226
- async function nearNote(path) {
227
- const { folder, missing, near } = await nearNames(path)
228
- if (!folder) return ''
229
- if (near.length) return ` Near it: ${near.join(', ')}.`
230
- return ` ${folder} is there, with nothing in it named like ${missing}.`
231
- }
232
-
233
- /** The tool call's arguments as the command's input: a path read into the
234
- * bytes it names. Returns the input, the input field the bytes go in, and
235
- * the file read — a folder's one file, when the path named a folder. */
236
- export async function inputFor(command, args, { cwd }) {
237
- const input = { ...(args ?? {}) }
238
- delete input.saveTo
239
- const bytesKey = bytesProperty(command)
240
- if (bytesKey) {
241
- const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
242
- delete input.path
243
- let bytes
244
- try {
245
- bytes = new Uint8Array(await readFile(file))
246
- } catch (e) {
247
- if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
248
- throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
249
- }
250
- input[bytesKey] = bytes
251
- if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
252
- return { input, binaryKey: bytesKey, file }
253
- }
254
- if (command.returnsFile || command.returnsImage) delete input.path
255
- if (command.returnsImage) delete input.inline
256
- return { input, binaryKey: null, file: null }
257
- }
258
-
259
- /** Arguments with every `{ "$file": path }` in them — an object of that one
260
- * key, at any depth — replaced by the JSON the file holds: a long list of
261
- * stations a script wrote need not pass through the conversation. A
262
- * relative path is taken from `cwd`. What a file holds is not looked
263
- * into again. */
264
- export async function withFiles(value, cwd) {
265
- if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
266
- if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
267
- const keys = Object.keys(value)
268
- if (keys.length === 1 && keys[0] === '$file') {
269
- if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
270
- const path = absolute(value.$file, cwd)
271
- try {
272
- return JSON.parse(await readFile(path, 'utf8'))
273
- } catch (e) {
274
- throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
275
- }
276
- }
277
- const out = {}
278
- for (const key of keys) out[key] = await withFiles(value[key], cwd)
279
- return out
280
- }
281
-
282
- /** A file's kind by its extension, as scanruler_files lists it. */
283
- const KINDS = {
284
- mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
285
- project: ['.scanruler'],
286
- step: ['.step', '.stp'],
287
- picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
288
- drawing: ['.dxf', '.svg', '.pdf'],
289
- text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
290
- }
291
- export const fileKind = (name) => {
292
- const ext = extname(name).toLowerCase()
293
- return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
294
- }
295
-
296
- /** A picture a command handed back, as MCP content: the image, and the rest
297
- * of the result as text — where the picture was written, if the call gave
298
- * a path, in place of its bytes; written and not shown with `inline:
299
- * false`. Null for a result with no picture in it. */
300
- export async function imageContent(result, args, { cwd, outDir }) {
301
- const file = result?.file
302
- if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
303
- const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
304
- if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
305
- return [
306
- { type: 'text', text: agentJson(saved) },
307
- { type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
308
- ]
309
- }
310
-
311
- /** A picture a batched call handed back, which a batch cannot show:
312
- * written where the call asked, or into `outDir` under the name ScanRuler
313
- * gave it — numbered past one already there, so a batch of renders, or a
314
- * render from an earlier batch, is not written over. The result says
315
- * where, in place of the bytes. A result with no picture in it is as
316
- * saveFileResult leaves it. */
317
- export async function batchedPicture(result, args, { cwd, outDir }) {
318
- const file = result?.file
319
- if (args?.path || !file || !(file.bytes instanceof Uint8Array)) return saveFileResult(result, args, { cwd, outDir })
320
- const name = String(file.name ?? 'picture.png')
321
- const dot = name.lastIndexOf('.')
322
- const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
323
- let target = join(outDir, name)
324
- for (let n = 2; await stat(target).catch(() => null); n++) target = join(outDir, `${stem}-${n}${ext}`)
325
- return saveFileResult(result, { path: target }, { cwd, outDir })
326
- }
327
-
328
- /** Write a returned file where the call asked, or into `outDir`; the result
329
- * then says where, in place of the bytes. */
330
- export async function saveFileResult(result, args, { cwd, outDir }) {
331
- const file = result?.file
332
- if (!file || !(file.bytes instanceof Uint8Array)) return result
333
- let target = args?.path ? absolute(String(args.path), cwd) : join(outDir, file.name)
334
- const existing = await stat(target).catch(() => null)
335
- if (existing?.isDirectory()) target = join(target, file.name)
336
- await mkdir(dirname(target), { recursive: true })
337
- await writeFile(target, file.bytes)
338
- return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
339
- }
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // The page's commands as MCP tools. A command's name becomes the tool's,
3
+ // dots to underscores — element.fit is element_fit — and its JSON Schema
4
+ // passes through, but for files: a command that takes a file's bytes takes a
5
+ // path on this computer instead, and this server reads the file and streams
6
+ // it to the page; a command that hands a file back takes an optional path,
7
+ // and this server writes the file there. The agent never sees the bytes —
8
+ // but for a picture (a command marked returnsImage), which it is shown as an
9
+ // image, and which is written to a file only when it gives a path — or, in
10
+ // a batch, where it cannot be shown, always.
11
+
12
+ import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
13
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
14
+
15
+ export const toolName = (command) => command.name.replace(/\./g, '_')
16
+
17
+ /** The input property that carries a file's bytes, if the command has one. */
18
+ export function bytesProperty(command) {
19
+ const props = command.input?.properties ?? {}
20
+ return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
21
+ }
22
+
23
+ /** Where a relative path is taken from, as every path field says it: the
24
+ * folder a scan or a project was last opened from or saved into — as
25
+ * scanruler_write and scanruler_files take it — else the folder the
26
+ * server started in (mcp.js, pathBase). */
27
+ export const relativeFrom = (cwd) =>
28
+ `A relative path is taken from the folder of the scan or project last opened or saved (scanruler_status: server.relativeTo) — before any, from ${cwd ?? 'the folder this server started in'}.`
29
+
30
+ const inPath = (cwd) => ({
31
+ type: 'string',
32
+ minLength: 1,
33
+ description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
34
+ })
35
+
36
+ const outPath = (outDir, cwd) => ({
37
+ type: 'string',
38
+ minLength: 1,
39
+ description: `Where to write the file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a file of that name there (-2, -3 …). Without it, it goes into ${outDir} the same way. ${relativeFrom(cwd)}`,
40
+ })
41
+
42
+ const imagePath = (outDir, cwd) => ({
43
+ type: 'string',
44
+ minLength: 1,
45
+ description: `Also write the picture to a file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a picture of that name there (-2, -3 …). Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written under a new name into the folder of the scan or project last opened or saved (the latest of scanruler_status folders), before any into ${outDir}. ${relativeFrom(cwd)}`,
46
+ })
47
+
48
+ /** The `saveTo` a command that replaces the session takes — one with
49
+ * `discard`: the session saved as a project file first (mcp.js). */
50
+ const saveToIn = (cwd) => ({
51
+ type: 'string',
52
+ minLength: 1,
53
+ description: `Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo). ${relativeFrom(cwd)}`,
54
+ })
55
+
56
+ /** Whether a command replaces the session, refusing to over work unless
57
+ * told `discard` — what `saveTo` is offered on. */
58
+ export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
59
+
60
+ const inlineIn = {
61
+ type: 'boolean',
62
+ description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
63
+ }
64
+
65
+ /** How many significant digits a number keeps in an answer, and the finest
66
+ * step it is given to: six digits is a micrometre on a part under a metre
67
+ * and a millionth of a unit vector; past them the page's doubles are
68
+ * noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
69
+ const DIGITS = 6
70
+ const STEP = 1e6
71
+
72
+ const tidy = (v) => {
73
+ const r = Number(v.toPrecision(DIGITS))
74
+ return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
75
+ }
76
+
77
+ /** An answer as the agent reads it: JSON, every number that is not whole
78
+ * kept to DIGITS significant digits and no finer than a millionth — 1e-17,
79
+ * a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
80
+ * are as they were. An `exact` answer is one whose numbers are handed
81
+ * back as inputs — a plane's frame, say — and keeps every digit:
82
+ * shortened, what is built on them is a hair off what it was meant to
83
+ * meet. */
84
+ export const agentJson = (value, exact = false) =>
85
+ exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
86
+
87
+ /** A command as the MCP tool an agent sees. */
88
+ /** The `wait` a tool that changes the session takes, and a long read
89
+ * (Command.long): the server runs every such call as a job (mcp.js), and
90
+ * false answers with the job at once. */
91
+ export const waitIn = {
92
+ type: 'boolean',
93
+ description: 'false: answer at once with { queued: true, job } while the call runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done — or, still running at the server’s answer limit (scanruler_status: server.answerWithin, seconds), with { queued: true, job, after } all the same.',
94
+ }
95
+
96
+ /** The `timings` a tool that changes the session takes: the server keeps
97
+ * what the page's answer carries of the build's steps (mcp.js). */
98
+ export const timingsIn = {
99
+ type: 'boolean',
100
+ description: 'true: the answer adds ms (the call, start to answer) and, where the change built, steps.played — each step the build played with its ms (steps.restored counts those taken up from a kept state, not played).',
101
+ }
102
+
103
+ export function toolFor(command, { outDir, cwd }) {
104
+ const input = structuredClone(command.input ?? { type: 'object' })
105
+ input.properties = { ...(input.properties ?? {}) }
106
+ let required = [...(input.required ?? [])]
107
+ const bytesKey = bytesProperty(command)
108
+ let description = command.description
109
+ if (bytesKey) {
110
+ delete input.properties[bytesKey]
111
+ const pick = FOLDER_PICKS[command.name]
112
+ const read = inPath(cwd)
113
+ input.properties.path = pick ? { ...read, description: `${read.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : read
114
+ required = required.filter((key) => key !== bytesKey && key !== 'name')
115
+ if (!required.includes('path')) required.unshift('path')
116
+ if (input.properties.name) {
117
+ input.properties.name = { ...input.properties.name, description: `${input.properties.name.description ?? ''} Defaults to the file's own name.`.trim() }
118
+ }
119
+ description += ' The file is read from `path` on this computer and streamed to the page.'
120
+ }
121
+ if (command.returnsImage) {
122
+ input.properties.path = imagePath(outDir, cwd)
123
+ input.properties.inline = inlineIn
124
+ description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
125
+ } else if (command.returnsFile) {
126
+ input.properties.path = outPath(outDir, cwd)
127
+ description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
128
+ }
129
+ if (replacesSession(command)) {
130
+ input.properties.saveTo = saveToIn(cwd)
131
+ description += ' With `saveTo`, the session is saved as a project file there first (as project_save does), then replaced as with discard: true — one call to keep what a page holds and start anew.'
132
+ }
133
+ if (!command.readOnly) {
134
+ input.properties.wait = waitIn
135
+ input.properties.timings = timingsIn
136
+ } else if (command.long) {
137
+ // A long read runs as a job too (mcp.js), so it takes wait.
138
+ input.properties.wait = waitIn
139
+ }
140
+ if (required.length) input.required = required
141
+ else delete input.required
142
+ return {
143
+ name: toolName(command),
144
+ title: command.title,
145
+ description,
146
+ inputSchema: input,
147
+ annotations: { title: command.title, readOnlyHint: Boolean(command.readOnly) && (!command.returnsFile || Boolean(command.returnsImage)), openWorldHint: false },
148
+ }
149
+ }
150
+
151
+ /** A path as the agent gave it, made absolute against `base`. */
152
+ export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
153
+
154
+ /** What a folder given in place of a file opens, by command: the one file
155
+ * of its kind in it. Other commands take a file only. */
156
+ const FOLDER_PICKS = {
157
+ 'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj', '.3mf'] },
158
+ 'project.load': { what: 'project', kinds: ['.scanruler'] },
159
+ }
160
+
161
+ /** The file a path names: itself, or — for a folder, where the command
162
+ * takes one (FOLDER_PICKS) — the only file of the command's kind in it.
163
+ * Refused with the folder's files listed otherwise. */
164
+ async function fileAt(command, file) {
165
+ const found = await stat(file).catch(() => null)
166
+ if (!found?.isDirectory()) return file
167
+ const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
168
+ const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
169
+ const pick = FOLDER_PICKS[command.name]
170
+ if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
171
+ const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
172
+ if (matches.length === 1) return join(file, matches[0])
173
+ if (matches.length === 0) {
174
+ throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
175
+ }
176
+ throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
177
+ }
178
+
179
+ /** How many near names a missing path's refusal lists at most, and how many
180
+ * letters off a near name may be. */
181
+ const NEAR_LISTED = 5
182
+ const NEAR_LETTERS = 2
183
+
184
+ /** A name as nearNames compares it: case ignored, and "-", "_" and a
185
+ * space taken alike. */
186
+ const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
187
+
188
+ /** How many letters `a` and `b` are apart — added, taken out or changed —
189
+ * or `most + 1` once past `most`. */
190
+ function lettersApart(a, b, most) {
191
+ if (Math.abs(a.length - b.length) > most) return most + 1
192
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j)
193
+ for (let i = 1; i <= a.length; i++) {
194
+ const next = [i]
195
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
196
+ if (Math.min(...next) > most) return most + 1
197
+ row = next
198
+ }
199
+ return row[b.length]
200
+ }
201
+
202
+ /** Where a path that is not there may have been meant: the first folder up
203
+ * from it that is there, the name in the path missing from it, and up to
204
+ * NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
205
+ * path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
206
+ * given with the rest of the path when that is in it. */
207
+ export async function nearNames(path) {
208
+ let missing = resolve(path)
209
+ let folder = dirname(missing)
210
+ let rest = []
211
+ for (;;) {
212
+ if ((await stat(folder).catch(() => null))?.isDirectory()) break
213
+ if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
214
+ rest = [basename(missing), ...rest]
215
+ missing = folder
216
+ folder = dirname(folder)
217
+ }
218
+ const want = nearForm(basename(missing))
219
+ const names = await readdir(folder).catch(() => [])
220
+ const near = names
221
+ .map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
222
+ .filter((n) => n.apart <= NEAR_LETTERS)
223
+ .sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
224
+ .slice(0, NEAR_LISTED)
225
+ const paths = []
226
+ for (const { name } of near) {
227
+ const whole = join(folder, name, ...rest)
228
+ paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
229
+ }
230
+ return { folder, missing: basename(missing), near: paths }
231
+ }
232
+
233
+ /** nearNames as a refusal's last sentence. */
234
+ async function nearNote(path) {
235
+ const { folder, missing, near } = await nearNames(path)
236
+ if (!folder) return ''
237
+ if (near.length) return ` Near it: ${near.join(', ')}.`
238
+ return ` ${folder} is there, with nothing in it named like ${missing}.`
239
+ }
240
+
241
+ /** The tool call's arguments as the command's input: a path read into the
242
+ * bytes it names. Returns the input, the input field the bytes go in, and
243
+ * the file read — a folder's one file, when the path named a folder. */
244
+ export async function inputFor(command, args, { cwd }) {
245
+ const input = { ...(args ?? {}) }
246
+ delete input.saveTo
247
+ const bytesKey = bytesProperty(command)
248
+ if (bytesKey) {
249
+ const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
250
+ delete input.path
251
+ let bytes
252
+ try {
253
+ bytes = new Uint8Array(await readFile(file))
254
+ } catch (e) {
255
+ if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
256
+ throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
257
+ }
258
+ input[bytesKey] = bytes
259
+ if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
260
+ return { input, binaryKey: bytesKey, file }
261
+ }
262
+ if (command.returnsFile || command.returnsImage) delete input.path
263
+ if (command.returnsImage) delete input.inline
264
+ return { input, binaryKey: null, file: null }
265
+ }
266
+
267
+ /** Arguments with every `{ "$file": path }` in them — an object of that one
268
+ * key, at any depth — replaced by the JSON the file holds: a long list of
269
+ * stations a script wrote need not pass through the conversation. A
270
+ * relative path is taken from `cwd` — the folder the server takes
271
+ * relative paths from (mcp.js, pathBase). What a file holds is not looked
272
+ * into again. */
273
+ export async function withFiles(value, cwd) {
274
+ if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
275
+ if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
276
+ const keys = Object.keys(value)
277
+ if (keys.length === 1 && keys[0] === '$file') {
278
+ if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
279
+ const path = absolute(value.$file, cwd)
280
+ try {
281
+ return JSON.parse(await readFile(path, 'utf8'))
282
+ } catch (e) {
283
+ throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
284
+ }
285
+ }
286
+ const out = {}
287
+ for (const key of keys) out[key] = await withFiles(value[key], cwd)
288
+ return out
289
+ }
290
+
291
+ /** A file's kind by its extension, as scanruler_files lists it. */
292
+ const KINDS = {
293
+ mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
294
+ project: ['.scanruler'],
295
+ step: ['.step', '.stp'],
296
+ picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
297
+ drawing: ['.dxf', '.svg', '.pdf'],
298
+ text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
299
+ }
300
+ export const fileKind = (name) => {
301
+ const ext = extname(name).toLowerCase()
302
+ return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
303
+ }
304
+
305
+ /** A picture a command handed back, as MCP content: the image, and the rest
306
+ * of the result as text — where the picture was written, if the call gave
307
+ * a path, in place of its bytes; written and not shown with `inline:
308
+ * false`. Null for a result with no picture in it. */
309
+ export async function imageContent(result, args, { cwd, outDir }) {
310
+ const file = result?.file
311
+ if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
312
+ const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
313
+ if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
314
+ return [
315
+ { type: 'text', text: agentJson(saved) },
316
+ { type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
317
+ ]
318
+ }
319
+
320
+ /** How many numbered names writeNew tries before it gives up. */
321
+ const NUMBERED_MOST = 10_000
322
+
323
+ /** `data` written to a new file in `folder` under `name` — or, a file of
324
+ * that name being there, as `stem-2.ext`, `stem-3.ext` …: each is made
325
+ * with `wx`, which refuses one that is there, so two written at once —
326
+ * pictures rendered side by side, answers spilled together — never take
327
+ * the same name, and nothing earlier is written over. Returns its path. */
328
+ export async function writeNew(folder, name, data) {
329
+ await mkdir(folder, { recursive: true })
330
+ const dot = name.lastIndexOf('.')
331
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
332
+ for (let n = 1; n <= NUMBERED_MOST; n++) {
333
+ const target = join(folder, n === 1 ? name : `${stem}-${n}${ext}`)
334
+ try {
335
+ await writeFile(target, data, { flag: 'wx' })
336
+ return target
337
+ } catch (e) {
338
+ if (e?.code !== 'EEXIST') throw e
339
+ }
340
+ }
341
+ throw { code: 'failed', message: `${folder} already holds ${NUMBERED_MOST} files named like ${name}: give a path to write it to.` }
342
+ }
343
+
344
+ /** A picture a batched call handed back, which a batch cannot show:
345
+ * written where the call asked, or into `folder` — the latest a scan or
346
+ * a project came from, which a sandboxed agent can open — else, or when
347
+ * that folder takes no file, into `outDir`; as saveFileResult writes any
348
+ * file, numbered past one already there, so a batch of renders, or a
349
+ * render from an earlier batch, is not written over. The result says
350
+ * where, in place of the bytes. */
351
+ export async function batchedPicture(result, args, { cwd, outDir, folder }) {
352
+ if (args?.path || !folder) return saveFileResult(result, args, { cwd, outDir })
353
+ try {
354
+ return await saveFileResult(result, args, { cwd, outDir: folder })
355
+ } catch {
356
+ return saveFileResult(result, args, { cwd, outDir })
357
+ }
358
+ }
359
+
360
+ /** Write a returned file where the call asked, or into `outDir`; the result
361
+ * then says where, in place of the bytes. A file path given is written,
362
+ * over one there; a folder — one there, or a path ending in a separator —
363
+ * or `outDir` takes the name ScanRuler gave the file, numbered past one
364
+ * already there (writeNew). */
365
+ export async function saveFileResult(result, args, { cwd, outDir }) {
366
+ const file = result?.file
367
+ if (!file || !(file.bytes instanceof Uint8Array)) return result
368
+ const name = basename(String(file.name ?? 'file'))
369
+ const given = args?.path ? absolute(String(args.path), cwd) : null
370
+ const folder = given === null ? outDir : /[\\/]$/.test(String(args.path)) || (await stat(given).catch(() => null))?.isDirectory() ? given : null
371
+ let target
372
+ if (folder !== null) target = await writeNew(folder, name, file.bytes)
373
+ else {
374
+ target = given
375
+ await mkdir(dirname(target), { recursive: true })
376
+ await writeFile(target, file.bytes)
377
+ }
378
+ return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
379
+ }