scanruler-mcp 0.4.0 → 0.4.5
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 +41 -8
- package/package.json +14 -3
- package/src/commands.json +257 -60
- package/src/config.js +24 -2
- package/src/link.js +1 -1
- package/src/main.js +3 -3
- package/src/mcp.js +413 -63
- package/src/relay.js +10 -0
- package/src/tools.js +75 -35
package/src/relay.js
CHANGED
|
@@ -24,6 +24,9 @@ export class PeerLink extends Asker {
|
|
|
24
24
|
this.token = token
|
|
25
25
|
this.version = version
|
|
26
26
|
this.log = log
|
|
27
|
+
/** The holding copy's version, as its welcome said; null from a copy
|
|
28
|
+
* before 0.4.1, whose welcome to another copy carried none. */
|
|
29
|
+
this.holderVersion = null
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** Connect to the copy on the port. Resolves `{ ok: true }` once it took
|
|
@@ -40,6 +43,7 @@ export class PeerLink extends Asker {
|
|
|
40
43
|
if (message.welcome) {
|
|
41
44
|
accepted = true
|
|
42
45
|
this.socket = ws
|
|
46
|
+
this.holderVersion = typeof message.welcome.version === 'string' ? message.welcome.version : null
|
|
43
47
|
this.#setPage(message.welcome.page ?? null)
|
|
44
48
|
resolve({ ok: true })
|
|
45
49
|
} else resolve({ refused: String(message.refused ?? 'It said no.') })
|
|
@@ -141,6 +145,12 @@ export class SharedLink extends EventEmitter {
|
|
|
141
145
|
return this.current?.page ?? null
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/** While relaying: the version of the copy holding the port, null when
|
|
149
|
+
* it is from before 0.4.1 and did not say. Undefined otherwise. */
|
|
150
|
+
get holderVersion() {
|
|
151
|
+
return this.role === 'relay' ? (this.peer?.holderVersion ?? null) : undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
run(name, input, binaryKey, onProgress, job, deferred = false) {
|
|
145
155
|
const link = this.current
|
|
146
156
|
if (!link) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
|
package/src/tools.js
CHANGED
|
@@ -20,31 +20,38 @@ export function bytesProperty(command) {
|
|
|
20
20
|
return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
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) => ({
|
|
24
31
|
type: 'string',
|
|
25
32
|
minLength: 1,
|
|
26
|
-
description:
|
|
27
|
-
}
|
|
33
|
+
description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
|
|
34
|
+
})
|
|
28
35
|
|
|
29
|
-
const outPath = (outDir) => ({
|
|
36
|
+
const outPath = (outDir, cwd) => ({
|
|
30
37
|
type: 'string',
|
|
31
38
|
minLength: 1,
|
|
32
|
-
description: `Where to write the file: a file path
|
|
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)}`,
|
|
33
40
|
})
|
|
34
41
|
|
|
35
|
-
const imagePath = (outDir) => ({
|
|
42
|
+
const imagePath = (outDir, cwd) => ({
|
|
36
43
|
type: 'string',
|
|
37
44
|
minLength: 1,
|
|
38
|
-
description: `Also write the picture to a file: a file path
|
|
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)}`,
|
|
39
46
|
})
|
|
40
47
|
|
|
41
48
|
/** The `saveTo` a command that replaces the session takes — one with
|
|
42
49
|
* `discard`: the session saved as a project file first (mcp.js). */
|
|
43
|
-
const saveToIn = {
|
|
50
|
+
const saveToIn = (cwd) => ({
|
|
44
51
|
type: 'string',
|
|
45
52
|
minLength: 1,
|
|
46
|
-
description:
|
|
47
|
-
}
|
|
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
|
+
})
|
|
48
55
|
|
|
49
56
|
/** Whether a command replaces the session, refusing to over work unless
|
|
50
57
|
* told `discard` — what `saveTo` is offered on. */
|
|
@@ -93,7 +100,7 @@ export const timingsIn = {
|
|
|
93
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).',
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
export function toolFor(command, { outDir }) {
|
|
103
|
+
export function toolFor(command, { outDir, cwd }) {
|
|
97
104
|
const input = structuredClone(command.input ?? { type: 'object' })
|
|
98
105
|
input.properties = { ...(input.properties ?? {}) }
|
|
99
106
|
let required = [...(input.required ?? [])]
|
|
@@ -102,7 +109,8 @@ export function toolFor(command, { outDir }) {
|
|
|
102
109
|
if (bytesKey) {
|
|
103
110
|
delete input.properties[bytesKey]
|
|
104
111
|
const pick = FOLDER_PICKS[command.name]
|
|
105
|
-
|
|
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
|
|
106
114
|
required = required.filter((key) => key !== bytesKey && key !== 'name')
|
|
107
115
|
if (!required.includes('path')) required.unshift('path')
|
|
108
116
|
if (input.properties.name) {
|
|
@@ -111,15 +119,15 @@ export function toolFor(command, { outDir }) {
|
|
|
111
119
|
description += ' The file is read from `path` on this computer and streamed to the page.'
|
|
112
120
|
}
|
|
113
121
|
if (command.returnsImage) {
|
|
114
|
-
input.properties.path = imagePath(outDir)
|
|
122
|
+
input.properties.path = imagePath(outDir, cwd)
|
|
115
123
|
input.properties.inline = inlineIn
|
|
116
124
|
description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
|
|
117
125
|
} else if (command.returnsFile) {
|
|
118
|
-
input.properties.path = outPath(outDir)
|
|
126
|
+
input.properties.path = outPath(outDir, cwd)
|
|
119
127
|
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
120
128
|
}
|
|
121
129
|
if (replacesSession(command)) {
|
|
122
|
-
input.properties.saveTo = saveToIn
|
|
130
|
+
input.properties.saveTo = saveToIn(cwd)
|
|
123
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.'
|
|
124
132
|
}
|
|
125
133
|
if (!command.readOnly) {
|
|
@@ -259,7 +267,8 @@ export async function inputFor(command, args, { cwd }) {
|
|
|
259
267
|
/** Arguments with every `{ "$file": path }` in them — an object of that one
|
|
260
268
|
* key, at any depth — replaced by the JSON the file holds: a long list of
|
|
261
269
|
* stations a script wrote need not pass through the conversation. A
|
|
262
|
-
* relative path is taken from `cwd
|
|
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
|
|
263
272
|
* into again. */
|
|
264
273
|
export async function withFiles(value, cwd) {
|
|
265
274
|
if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
|
|
@@ -308,32 +317,63 @@ export async function imageContent(result, args, { cwd, outDir }) {
|
|
|
308
317
|
]
|
|
309
318
|
}
|
|
310
319
|
|
|
311
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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 })
|
|
321
330
|
const dot = name.lastIndexOf('.')
|
|
322
331
|
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
|
|
323
|
-
let
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
}
|
|
326
358
|
}
|
|
327
359
|
|
|
328
360
|
/** Write a returned file where the call asked, or into `outDir`; the result
|
|
329
|
-
* then says where, in place of the bytes.
|
|
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). */
|
|
330
365
|
export async function saveFileResult(result, args, { cwd, outDir }) {
|
|
331
366
|
const file = result?.file
|
|
332
367
|
if (!file || !(file.bytes instanceof Uint8Array)) return result
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
await
|
|
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
|
+
}
|
|
338
378
|
return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
|
|
339
379
|
}
|