scanruler-mcp 0.2.0 → 0.4.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
@@ -6,10 +6,11 @@
6
6
  // it to the page; a command that hands a file back takes an optional path,
7
7
  // and this server writes the file there. The agent never sees the bytes —
8
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.
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.
10
11
 
11
- import { readFile, stat, writeFile, mkdir } from 'node:fs/promises'
12
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
12
+ import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
13
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
13
14
 
14
15
  export const toolName = (command) => command.name.replace(/\./g, '_')
15
16
 
@@ -31,10 +32,27 @@ const outPath = (outDir) => ({
31
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}.`,
32
33
  })
33
34
 
34
- const imagePath = {
35
+ const imagePath = (outDir) => ({
35
36
  type: 'string',
36
37
  minLength: 1,
37
- 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.',
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.',
38
56
  }
39
57
 
40
58
  /** How many significant digits a number keeps in an answer, and the finest
@@ -60,6 +78,21 @@ export const agentJson = (value, exact = false) =>
60
78
  exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
61
79
 
62
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
+
63
96
  export function toolFor(command, { outDir }) {
64
97
  const input = structuredClone(command.input ?? { type: 'object' })
65
98
  input.properties = { ...(input.properties ?? {}) }
@@ -68,7 +101,8 @@ export function toolFor(command, { outDir }) {
68
101
  let description = command.description
69
102
  if (bytesKey) {
70
103
  delete input.properties[bytesKey]
71
- input.properties.path = inPath
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
72
106
  required = required.filter((key) => key !== bytesKey && key !== 'name')
73
107
  if (!required.includes('path')) required.unshift('path')
74
108
  if (input.properties.name) {
@@ -77,12 +111,24 @@ export function toolFor(command, { outDir }) {
77
111
  description += ' The file is read from `path` on this computer and streamed to the page.'
78
112
  }
79
113
  if (command.returnsImage) {
80
- input.properties.path = imagePath
81
- description += ' The picture comes back as an image; with `path` it is written to a file too.'
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.'
82
117
  } else if (command.returnsFile) {
83
118
  input.properties.path = outPath(outDir)
84
119
  description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
85
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
+ }
86
132
  if (required.length) input.required = required
87
133
  else delete input.required
88
134
  return {
@@ -97,41 +143,188 @@ export function toolFor(command, { outDir }) {
97
143
  /** A path as the agent gave it, made absolute against `base`. */
98
144
  export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
99
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
+
100
233
  /** The tool call's arguments as the command's input: a path read into the
101
- * bytes it names. Returns the input and the input field the bytes go in. */
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. */
102
236
  export async function inputFor(command, args, { cwd }) {
103
237
  const input = { ...(args ?? {}) }
238
+ delete input.saveTo
104
239
  const bytesKey = bytesProperty(command)
105
240
  if (bytesKey) {
106
- const file = absolute(String(input.path ?? ''), cwd)
241
+ const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
107
242
  delete input.path
108
243
  let bytes
109
244
  try {
110
245
  bytes = new Uint8Array(await readFile(file))
111
246
  } catch (e) {
112
- throw { code: 'invalid_input', message: `Could not read ${file}: ${e.code === 'ENOENT' ? 'there is no such file' : e.message}.` }
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)}` }
113
249
  }
114
250
  input[bytesKey] = bytes
115
251
  if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
116
- return { input, binaryKey: bytesKey }
252
+ return { input, binaryKey: bytesKey, file }
117
253
  }
118
254
  if (command.returnsFile || command.returnsImage) delete input.path
119
- return { input, binaryKey: null }
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'
120
294
  }
121
295
 
122
296
  /** A picture a command handed back, as MCP content: the image, and the rest
123
297
  * of the result as text — where the picture was written, if the call gave
124
- * a path, in place of its bytes. Null for a result with no picture in it. */
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. */
125
300
  export async function imageContent(result, args, { cwd, outDir }) {
126
301
  const file = result?.file
127
302
  if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
128
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) }]
129
305
  return [
130
306
  { type: 'text', text: agentJson(saved) },
131
307
  { type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
132
308
  ]
133
309
  }
134
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
+
135
328
  /** Write a returned file where the call asked, or into `outDir`; the result
136
329
  * then says where, in place of the bytes. */
137
330
  export async function saveFileResult(result, args, { cwd, outDir }) {
package/src/trace.js ADDED
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // The call trace: one JSON line per tool call into a file of the session's
3
+ // own, for measuring a run afterwards — how long each call took, how much
4
+ // went each way, what was refused, and whether the tab was hidden. Off
5
+ // unless asked for (--trace <dir>, SCANRULER_MCP_TRACE); local only; an
6
+ // argument's contents are written only with --trace-args, its size always.
7
+ // scripts/trace-summary.mjs reads the files.
8
+
9
+ import { appendFileSync, mkdirSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ /** Bytes of a value as JSON, UTF-8. */
13
+ export const jsonBytes = (value) => (value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value), 'utf8'))
14
+
15
+ /** What an MCP reply weighs: its text in UTF-8 bytes, a picture by its
16
+ * base64 — what the client is handed — and the error code of a refusal. */
17
+ export function replyMeasure(reply) {
18
+ let out = 0
19
+ let images = 0
20
+ for (const c of reply?.content ?? []) {
21
+ if (c.type === 'text') out += Buffer.byteLength(c.text ?? '', 'utf8')
22
+ else if (c.type === 'image') {
23
+ out += (c.data ?? '').length
24
+ images++
25
+ }
26
+ }
27
+ const text = reply?.content?.find((c) => c.type === 'text')?.text ?? ''
28
+ const error = reply?.isError ? (/^([a-z_]+): /.exec(text)?.[1] ?? 'failed') : null
29
+ return { out, error, ...(images ? { images } : {}) }
30
+ }
31
+
32
+ /** A trace into a new file in `dir`, or null when `dir` is empty. Each
33
+ * line is a JSON object with `ev`: "start" (the server), "list" (the tool
34
+ * list handed out), "call" (a tool call answered), "entry" (one call of a
35
+ * batch), "job" (a change started with wait: false, finished) and
36
+ * "visibility" (the tab hidden or shown). Times are ms since the epoch
37
+ * (`t`, the start) and durations ms. */
38
+ export function createTrace(dir, { args = false, now = () => Date.now(), pid = process.pid } = {}) {
39
+ if (!dir) return null
40
+ mkdirSync(dir, { recursive: true })
41
+ const stamp = new Date(now()).toISOString().replace(/[:.]/g, '-').replace(/Z$/, '')
42
+ const file = join(dir, `scanruler-mcp-${stamp}-${pid}.jsonl`)
43
+ let broken = false
44
+ const write = (line) => {
45
+ if (broken) return
46
+ try {
47
+ appendFileSync(file, `${JSON.stringify(line)}\n`)
48
+ } catch (e) {
49
+ // A trace that cannot be written is not a reason to fail the call.
50
+ broken = true
51
+ process.stderr.write(`[scanruler-mcp] The trace stopped: ${e?.message ?? e}\n`)
52
+ }
53
+ }
54
+ return { file, args, now, write }
55
+ }