scanruler-mcp 0.3.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/README.md +107 -40
- package/bin/scanruler-mcp.js +15 -8
- package/package.json +1 -1
- package/src/commands.json +4611 -3584
- package/src/config.js +38 -6
- package/src/link.js +304 -293
- package/src/main.js +44 -11
- package/src/mcp.js +1155 -615
- package/src/relay.js +218 -218
- package/src/tools.js +192 -17
- package/src/update.js +380 -0
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:
|
|
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,11 +78,12 @@ 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. */
|
|
63
|
-
/** The `wait` a tool that changes the session takes
|
|
64
|
-
* such call as a job (mcp.js), and
|
|
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. */
|
|
65
84
|
export const waitIn = {
|
|
66
85
|
type: 'boolean',
|
|
67
|
-
description: 'false: answer at once with { queued: true, job } while the
|
|
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.',
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
/** The `timings` a tool that changes the session takes: the server keeps
|
|
@@ -82,7 +101,8 @@ export function toolFor(command, { outDir }) {
|
|
|
82
101
|
let description = command.description
|
|
83
102
|
if (bytesKey) {
|
|
84
103
|
delete input.properties[bytesKey]
|
|
85
|
-
|
|
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
|
|
86
106
|
required = required.filter((key) => key !== bytesKey && key !== 'name')
|
|
87
107
|
if (!required.includes('path')) required.unshift('path')
|
|
88
108
|
if (input.properties.name) {
|
|
@@ -91,15 +111,23 @@ export function toolFor(command, { outDir }) {
|
|
|
91
111
|
description += ' The file is read from `path` on this computer and streamed to the page.'
|
|
92
112
|
}
|
|
93
113
|
if (command.returnsImage) {
|
|
94
|
-
input.properties.path = imagePath
|
|
95
|
-
|
|
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.'
|
|
96
117
|
} else if (command.returnsFile) {
|
|
97
118
|
input.properties.path = outPath(outDir)
|
|
98
119
|
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
99
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
|
+
}
|
|
100
125
|
if (!command.readOnly) {
|
|
101
126
|
input.properties.wait = waitIn
|
|
102
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
|
|
103
131
|
}
|
|
104
132
|
if (required.length) input.required = required
|
|
105
133
|
else delete input.required
|
|
@@ -115,41 +143,188 @@ export function toolFor(command, { outDir }) {
|
|
|
115
143
|
/** A path as the agent gave it, made absolute against `base`. */
|
|
116
144
|
export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
|
|
117
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
|
+
|
|
118
233
|
/** The tool call's arguments as the command's input: a path read into the
|
|
119
|
-
* bytes it names. Returns the input
|
|
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. */
|
|
120
236
|
export async function inputFor(command, args, { cwd }) {
|
|
121
237
|
const input = { ...(args ?? {}) }
|
|
238
|
+
delete input.saveTo
|
|
122
239
|
const bytesKey = bytesProperty(command)
|
|
123
240
|
if (bytesKey) {
|
|
124
|
-
const file = absolute(String(input.path ?? ''), cwd)
|
|
241
|
+
const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
|
|
125
242
|
delete input.path
|
|
126
243
|
let bytes
|
|
127
244
|
try {
|
|
128
245
|
bytes = new Uint8Array(await readFile(file))
|
|
129
246
|
} catch (e) {
|
|
130
|
-
throw { code: 'invalid_input', message: `Could not read ${file}: ${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)}` }
|
|
131
249
|
}
|
|
132
250
|
input[bytesKey] = bytes
|
|
133
251
|
if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
|
|
134
|
-
return { input, binaryKey: bytesKey }
|
|
252
|
+
return { input, binaryKey: bytesKey, file }
|
|
135
253
|
}
|
|
136
254
|
if (command.returnsFile || command.returnsImage) delete input.path
|
|
137
|
-
|
|
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'
|
|
138
294
|
}
|
|
139
295
|
|
|
140
296
|
/** A picture a command handed back, as MCP content: the image, and the rest
|
|
141
297
|
* of the result as text — where the picture was written, if the call gave
|
|
142
|
-
* a path, in place of its bytes
|
|
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. */
|
|
143
300
|
export async function imageContent(result, args, { cwd, outDir }) {
|
|
144
301
|
const file = result?.file
|
|
145
302
|
if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
|
|
146
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) }]
|
|
147
305
|
return [
|
|
148
306
|
{ type: 'text', text: agentJson(saved) },
|
|
149
307
|
{ type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
|
|
150
308
|
]
|
|
151
309
|
}
|
|
152
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
|
+
|
|
153
328
|
/** Write a returned file where the call asked, or into `outDir`; the result
|
|
154
329
|
* then says where, in place of the bytes. */
|
|
155
330
|
export async function saveFileResult(result, args, { cwd, outDir }) {
|
package/src/update.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// The server keeps itself current. A config written as `npx -y
|
|
3
|
+
// scanruler-mcp` is rarely changed again, and a copy installed or copied by
|
|
4
|
+
// hand never updates at all; so the server does it by itself, in two halves
|
|
5
|
+
// that never hold up the start:
|
|
6
|
+
//
|
|
7
|
+
// - In the background, once it serves, it asks npm's registry which version
|
|
8
|
+
// is latest — once a day, 2 s at most, the answer kept in update.json
|
|
9
|
+
// beside tools.json — and when that is newer than itself, has npm fetch it
|
|
10
|
+
// into npm's cache: `npm exec --yes --package=scanruler-mcp@<v> --
|
|
11
|
+
// scanruler-mcp --version`, which runs the fetched copy once, so a copy
|
|
12
|
+
// that does not start is never taken. The session in progress goes on.
|
|
13
|
+
// - At the next start the launcher (bin/scanruler-mcp.js) finds that copy in
|
|
14
|
+
// update.json (`ready: { version, bin }` — a contract every later version
|
|
15
|
+
// keeps, since the copy that reads it may be an old one) and runs it in its
|
|
16
|
+
// own place: the same arguments, stdio passed straight through, its exit
|
|
17
|
+
// code passed back. No network before serving.
|
|
18
|
+
//
|
|
19
|
+
// Every failure leaves the copy that is running: offline, registry down,
|
|
20
|
+
// fetch or start failed. Off for a copy run from a git checkout (the dev
|
|
21
|
+
// server), with --no-update or SCANRULER_MCP_NO_UPDATE=1, and for a spec
|
|
22
|
+
// pinned in the config (`npx -y scanruler-mcp@0.4.0` is taken as meant).
|
|
23
|
+
// The hand-off is marked in the environment, so a handed-to copy never hands
|
|
24
|
+
// off again in the same start.
|
|
25
|
+
//
|
|
26
|
+
// npx keeps each spec in its own folder, <npm cache>/_npx/<hash>, the hash
|
|
27
|
+
// the first 16 hex digits of the SHA-512 of the spec as written (npm's
|
|
28
|
+
// libnpmexec). A copy run by npx tells from that hash whether its spec was
|
|
29
|
+
// pinned, and finds npm's cache from its own path.
|
|
30
|
+
|
|
31
|
+
import { spawn } from 'node:child_process'
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
34
|
+
import { homedir, tmpdir } from 'node:os'
|
|
35
|
+
import { basename, dirname, join } from 'node:path'
|
|
36
|
+
import { fileURLToPath } from 'node:url'
|
|
37
|
+
import { configDir } from './config.js'
|
|
38
|
+
|
|
39
|
+
export const PACKAGE = 'scanruler-mcp'
|
|
40
|
+
/** Set on the copy handed to: the version that handed off. */
|
|
41
|
+
export const HANDED_OFF_ENV = 'SCANRULER_MCP_HANDED_OFF'
|
|
42
|
+
/** The launcher's process id, for the copy handed to to stop with it. */
|
|
43
|
+
export const PARENT_ENV = 'SCANRULER_MCP_PARENT_PID'
|
|
44
|
+
export const NO_UPDATE_ENV = 'SCANRULER_MCP_NO_UPDATE'
|
|
45
|
+
export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000
|
|
46
|
+
export const CHECK_WITHIN_MS = 2000
|
|
47
|
+
/** A handed-to copy that fails sooner than this is taken as not starting. */
|
|
48
|
+
export const START_GRACE_MS = 3000
|
|
49
|
+
const FETCH_WITHIN_MS = 5 * 60 * 1000
|
|
50
|
+
const STATE_FILE = 'update.json'
|
|
51
|
+
/** Options that answer and exit: never handed off. */
|
|
52
|
+
const OWN_ANSWERS = ['--help', '--pair', '--version']
|
|
53
|
+
|
|
54
|
+
const PACKAGE_DIR = fileURLToPath(new URL('..', import.meta.url))
|
|
55
|
+
|
|
56
|
+
// ---- versions -------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const parse = (v) => {
|
|
59
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(typeof v === 'string' ? v : '')
|
|
60
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? null] : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const isVersion = (v) => parse(v) !== null
|
|
64
|
+
|
|
65
|
+
/** Whether version `a` is newer than `b`; false when either is no version. */
|
|
66
|
+
export function newer(a, b) {
|
|
67
|
+
const x = parse(a)
|
|
68
|
+
const y = parse(b)
|
|
69
|
+
if (!x || !y) return false
|
|
70
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]
|
|
71
|
+
return x[3] === null && y[3] !== null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** npx's folder name for a spec: <npm cache>/_npx/<this>. */
|
|
75
|
+
export const npxHash = (spec) => createHash('sha512').update(spec).digest('hex').slice(0, 16)
|
|
76
|
+
|
|
77
|
+
/** The version of the copy whose bin is `bin`, or null. */
|
|
78
|
+
export function versionAt(bin, read = readFileSync) {
|
|
79
|
+
try {
|
|
80
|
+
const v = JSON.parse(read(join(dirname(bin), '..', 'package.json'), 'utf8')).version
|
|
81
|
+
return isVersion(v) ? v : null
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- how this copy runs -------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** How the copy in `packageDir` was installed: by npx (`npx`, with npm's
|
|
90
|
+
* cache, and pinned unless its spec was the bare name or @latest), by npm
|
|
91
|
+
* (`installed`: global or a project's), from a git checkout (`checkout`),
|
|
92
|
+
* or copied by hand (`copy`). */
|
|
93
|
+
export function installOf(packageDir = PACKAGE_DIR, exists = existsSync) {
|
|
94
|
+
const parent = dirname(packageDir)
|
|
95
|
+
if (basename(parent) === 'node_modules') {
|
|
96
|
+
const npxDir = dirname(parent)
|
|
97
|
+
if (basename(dirname(npxDir)) === '_npx') {
|
|
98
|
+
const hash = basename(npxDir)
|
|
99
|
+
const bare = [PACKAGE, `${PACKAGE}@latest`].some((spec) => npxHash(spec) === hash)
|
|
100
|
+
return { kind: 'npx', pinned: !bare, npmCache: dirname(dirname(npxDir)) }
|
|
101
|
+
}
|
|
102
|
+
return { kind: 'installed', pinned: false }
|
|
103
|
+
}
|
|
104
|
+
for (let dir = parent; ; dir = dirname(dir)) {
|
|
105
|
+
if (exists(join(dir, '.git'))) return { kind: 'checkout', pinned: false }
|
|
106
|
+
if (dirname(dir) === dir) return { kind: 'copy', pinned: false }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether this copy keeps itself current: `updates` is `on`, `off`
|
|
111
|
+
* (--no-update, SCANRULER_MCP_NO_UPDATE), `checkout` or `pinned`. A copy
|
|
112
|
+
* handed to goes by the launcher's decision, not its own folder — which is
|
|
113
|
+
* a pinned spec's. */
|
|
114
|
+
export function updatePolicy({ argv = [], env = process.env, install }) {
|
|
115
|
+
const handedOffFrom = env[HANDED_OFF_ENV] || null
|
|
116
|
+
const from = handedOffFrom ? { handedOffFrom } : {}
|
|
117
|
+
if (argv.includes('--no-update') || /^(1|true|yes|on)$/i.test(env[NO_UPDATE_ENV] ?? '')) return { updates: 'off', ...from }
|
|
118
|
+
if (handedOffFrom) return { updates: 'on', ...from }
|
|
119
|
+
if (install.kind === 'checkout') return { updates: 'checkout' }
|
|
120
|
+
if (install.pinned) return { updates: 'pinned', pinned: true }
|
|
121
|
+
return { updates: 'on' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- what is kept ---------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** update.json: `{ checked, latest, ready?: { version, bin }, failed? }`. */
|
|
127
|
+
export function loadState(dir = configDir()) {
|
|
128
|
+
try {
|
|
129
|
+
const kept = JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8'))
|
|
130
|
+
return kept && typeof kept === 'object' ? kept : {}
|
|
131
|
+
} catch {
|
|
132
|
+
return {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Merge `patch` into update.json — written whole and renamed into place,
|
|
137
|
+
* since two copies may start at once. */
|
|
138
|
+
export function saveState(patch, dir = configDir()) {
|
|
139
|
+
const next = { ...loadState(dir), ...patch }
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dir, { recursive: true })
|
|
142
|
+
const file = join(dir, STATE_FILE)
|
|
143
|
+
const part = `${file}.${process.pid}.part`
|
|
144
|
+
writeFileSync(part, JSON.stringify(next, null, 2) + '\n')
|
|
145
|
+
renameSync(part, file)
|
|
146
|
+
} catch {
|
|
147
|
+
// Not kept: the next start asks again.
|
|
148
|
+
}
|
|
149
|
+
return next
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- the launcher ---------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** What the launcher runs: `{ run: 'self', why }` or `{ run: 'handoff',
|
|
155
|
+
* bin, version }` — read from update.json and the disk alone. */
|
|
156
|
+
export function launchPlan({ argv = [], env = process.env, version, install, state = {}, exists = existsSync, readVersion = versionAt }) {
|
|
157
|
+
const self = (why) => ({ run: 'self', why })
|
|
158
|
+
if (env[HANDED_OFF_ENV]) return self('handed-off')
|
|
159
|
+
if (argv.some((a) => OWN_ANSWERS.includes(a))) return self('answer')
|
|
160
|
+
const policy = updatePolicy({ argv, env, install })
|
|
161
|
+
if (policy.updates !== 'on') return self(policy.updates)
|
|
162
|
+
const ready = state.ready
|
|
163
|
+
if (!ready || typeof ready.bin !== 'string' || !newer(ready.version, version)) return self('current')
|
|
164
|
+
if (Array.isArray(state.failed) && state.failed.includes(ready.version)) return self('failed-before')
|
|
165
|
+
if (!exists(ready.bin) || readVersion(ready.bin) !== ready.version) return self('missing')
|
|
166
|
+
return { run: 'handoff', bin: ready.bin, version: ready.version }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Run the copy at `plan.bin` in this one's place. Resolves `{ code }` with
|
|
170
|
+
* its exit code, or `{ fallback }` when it did not start — failed to
|
|
171
|
+
* spawn, or ended with an error within START_GRACE_MS, then marked failed
|
|
172
|
+
* so the next start does not try it again. */
|
|
173
|
+
export function runHandOff({ plan, argv, version, env = process.env, spawnFn = spawn, now = Date.now, markFailed = () => {}, execPath = process.execPath }) {
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
let child
|
|
176
|
+
try {
|
|
177
|
+
child = spawnFn(execPath, [plan.bin, ...argv], {
|
|
178
|
+
stdio: 'inherit',
|
|
179
|
+
env: { ...env, [HANDED_OFF_ENV]: version, [PARENT_ENV]: String(process.pid) },
|
|
180
|
+
windowsHide: true,
|
|
181
|
+
})
|
|
182
|
+
} catch (e) {
|
|
183
|
+
resolve({ fallback: `could not start it: ${e?.message ?? e}` })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const started = now()
|
|
187
|
+
const forward = (signal) => () => child.kill(signal)
|
|
188
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'].map((s) => [s, forward(s)])
|
|
189
|
+
for (const [s, f] of signals) process.on(s, f)
|
|
190
|
+
const done = (out) => {
|
|
191
|
+
for (const [s, f] of signals) process.off(s, f)
|
|
192
|
+
resolve(out)
|
|
193
|
+
}
|
|
194
|
+
child.once('error', (e) => done({ fallback: `could not start it: ${e?.message ?? e}` }))
|
|
195
|
+
child.once('exit', (code, signal) => {
|
|
196
|
+
if (code !== 0 && signal === null && now() - started < START_GRACE_MS) {
|
|
197
|
+
markFailed(plan.version)
|
|
198
|
+
done({ fallback: `it stopped with code ${code} as it started` })
|
|
199
|
+
} else done({ code: code ?? 1 })
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The launcher's part: run a newer copy fetched earlier in this one's
|
|
205
|
+
* place. Resolves its exit code, or null for this copy to run — always
|
|
206
|
+
* null on any failure. */
|
|
207
|
+
export async function launch(argv, { env = process.env, packageDir = PACKAGE_DIR, dir, log = stderrLog } = {}) {
|
|
208
|
+
try {
|
|
209
|
+
const version = versionAt(join(packageDir, 'bin', 'scanruler-mcp.js'))
|
|
210
|
+
const where = dir ?? configDir(env)
|
|
211
|
+
const plan = launchPlan({ argv, env, version, install: installOf(packageDir), state: loadState(where) })
|
|
212
|
+
if (plan.run !== 'handoff') return null
|
|
213
|
+
log(`Starting scanruler-mcp ${plan.version}, fetched earlier, in place of ${version}.`)
|
|
214
|
+
const out = await runHandOff({
|
|
215
|
+
plan,
|
|
216
|
+
argv,
|
|
217
|
+
version,
|
|
218
|
+
env,
|
|
219
|
+
markFailed: (v) => {
|
|
220
|
+
const failed = loadState(where).failed
|
|
221
|
+
saveState({ failed: [...(Array.isArray(failed) ? failed : []), v].slice(-5) }, where)
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
if ('code' in out) return out.code
|
|
225
|
+
log(`scanruler-mcp ${plan.version} did not start (${out.fallback}); running ${version}.`)
|
|
226
|
+
return null
|
|
227
|
+
} catch {
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** In a copy handed to: stop when the launcher is gone — killed by the
|
|
233
|
+
* client, which on Windows does not take its children with it. */
|
|
234
|
+
export function watchParent(env = process.env, { every = 2000, alive = processAlive, exit = () => process.exit(0) } = {}) {
|
|
235
|
+
const pid = Number(env[PARENT_ENV])
|
|
236
|
+
if (!env[HANDED_OFF_ENV] || !Number.isInteger(pid) || pid <= 0) return null
|
|
237
|
+
const timer = setInterval(() => {
|
|
238
|
+
if (!alive(pid)) exit()
|
|
239
|
+
}, every)
|
|
240
|
+
timer.unref?.()
|
|
241
|
+
return timer
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function processAlive(pid) {
|
|
245
|
+
try {
|
|
246
|
+
process.kill(pid, 0)
|
|
247
|
+
return true
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return e?.code === 'EPERM'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const stderrLog = (line) => process.stderr.write(`[scanruler-mcp] ${line}\n`)
|
|
254
|
+
|
|
255
|
+
// ---- the background check ---------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/** The latest version on the registry npm uses, or null — 2 s at most. */
|
|
258
|
+
export async function fetchLatest({ env = process.env, fetchFn = fetch, within = CHECK_WITHIN_MS } = {}) {
|
|
259
|
+
try {
|
|
260
|
+
const registry = (env.npm_config_registry || 'https://registry.npmjs.org/').replace(/\/?$/, '/')
|
|
261
|
+
const r = await fetchFn(`${registry}${PACKAGE}/latest`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(within) })
|
|
262
|
+
if (!r.ok) return null
|
|
263
|
+
const { version } = await r.json()
|
|
264
|
+
return isVersion(version) ? version : null
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** How to run npm without a shell: the npm that ran npx, or the one beside
|
|
271
|
+
* this Node; `npm` through the shell as the last resort. */
|
|
272
|
+
export function npmCommand(env = process.env, execPath = process.execPath, exists = existsSync) {
|
|
273
|
+
const given = env.npm_execpath ?? ''
|
|
274
|
+
const candidates = [
|
|
275
|
+
/npm-cli\.js$/.test(given) ? given : null,
|
|
276
|
+
/npx-cli\.js$/.test(given) ? join(dirname(given), 'npm-cli.js') : null,
|
|
277
|
+
join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
278
|
+
join(dirname(execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
279
|
+
]
|
|
280
|
+
const cli = candidates.find((c) => c && exists(c))
|
|
281
|
+
return cli ? { file: execPath, args: [cli], shell: false } : { file: 'npm', args: [], shell: process.platform === 'win32' }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Run npm with `args` (fixed words and a checked version only — they may
|
|
285
|
+
* pass a shell), quietly, from the temp folder: `{ code, stdout }`. */
|
|
286
|
+
export function runNpm(args, { env = process.env, within = FETCH_WITHIN_MS, spawnFn = spawn } = {}) {
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const npm = npmCommand(env)
|
|
289
|
+
let child
|
|
290
|
+
try {
|
|
291
|
+
child = spawnFn(npm.file, [...npm.args, ...args], {
|
|
292
|
+
cwd: tmpdir(),
|
|
293
|
+
env: { ...env, [NO_UPDATE_ENV]: '1', [HANDED_OFF_ENV]: '' },
|
|
294
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
295
|
+
shell: npm.shell,
|
|
296
|
+
windowsHide: true,
|
|
297
|
+
})
|
|
298
|
+
} catch {
|
|
299
|
+
resolve({ code: -1, stdout: '' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
let stdout = ''
|
|
303
|
+
child.stdout?.on('data', (d) => {
|
|
304
|
+
if (stdout.length < 4096) stdout += d
|
|
305
|
+
})
|
|
306
|
+
const timer = setTimeout(() => child.kill(), within)
|
|
307
|
+
timer.unref?.()
|
|
308
|
+
child.once('error', () => {
|
|
309
|
+
clearTimeout(timer)
|
|
310
|
+
resolve({ code: -1, stdout })
|
|
311
|
+
})
|
|
312
|
+
child.once('close', (code) => {
|
|
313
|
+
clearTimeout(timer)
|
|
314
|
+
resolve({ code: code ?? -1, stdout })
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** npm's cache folder when nobody says otherwise. */
|
|
320
|
+
const defaultNpmCache = (env, platform = process.platform) =>
|
|
321
|
+
platform === 'win32' ? join(env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'npm-cache') : join(homedir(), '.npm')
|
|
322
|
+
|
|
323
|
+
/** Have npm fetch `version` into its cache and run it once; the bin of the
|
|
324
|
+
* copy fetched, or null. */
|
|
325
|
+
export async function prefetch(version, { env = process.env, install = { kind: 'copy' }, npm = runNpm, readVersion = versionAt } = {}) {
|
|
326
|
+
if (!isVersion(version)) return null
|
|
327
|
+
const spec = `${PACKAGE}@${version}`
|
|
328
|
+
const ran = await npm(['exec', '--yes', `--package=${spec}`, '--', PACKAGE, '--version'], { env })
|
|
329
|
+
if (ran.code !== 0 || ran.stdout.trim() !== version) return null
|
|
330
|
+
const binIn = (cache) => join(cache, '_npx', npxHash(spec), 'node_modules', PACKAGE, 'bin', 'scanruler-mcp.js')
|
|
331
|
+
const caches = [install.npmCache, env.npm_config_cache, defaultNpmCache(env)].filter(Boolean)
|
|
332
|
+
for (const cache of caches) if (readVersion(binIn(cache)) === version) return binIn(cache)
|
|
333
|
+
const asked = await npm(['config', 'get', 'cache'], { env, within: 30_000 })
|
|
334
|
+
const cache = asked.code === 0 ? asked.stdout.trim() : ''
|
|
335
|
+
return cache && readVersion(binIn(cache)) === version ? binIn(cache) : null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The background check: ask the registry (once a day), and fetch a newer
|
|
339
|
+
* version for the next start. Resolves the state kept. */
|
|
340
|
+
export async function checkForUpdate({ version, dir = configDir(), env = process.env, install = { kind: 'copy' }, now = Date.now, latestOf = () => fetchLatest({ env }), fetchCopy = (v) => prefetch(v, { env, install }), exists = existsSync }) {
|
|
341
|
+
let state = loadState(dir)
|
|
342
|
+
const checked = Date.parse(state.checked ?? '')
|
|
343
|
+
if (!Number.isFinite(checked) || now() - checked > CHECK_EVERY_MS || now() < checked) {
|
|
344
|
+
const latest = await latestOf()
|
|
345
|
+
if (!latest) return state
|
|
346
|
+
state = saveState({ checked: new Date(now()).toISOString(), latest }, dir)
|
|
347
|
+
}
|
|
348
|
+
const latest = state.latest
|
|
349
|
+
if (!newer(latest, version)) return state
|
|
350
|
+
if (Array.isArray(state.failed) && state.failed.includes(latest)) return state
|
|
351
|
+
if (state.ready?.version === latest && exists(state.ready.bin)) return state
|
|
352
|
+
const bin = await fetchCopy(latest)
|
|
353
|
+
return bin ? saveState({ ready: { version: latest, bin, fetched: new Date(now()).toISOString() } }, dir) : state
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The server's part, once it serves: the check in the background, and
|
|
357
|
+
* what the status says of it — `{ latest, handedOffFrom?, pinned?,
|
|
358
|
+
* updates? }` (updates only when off: `off` or `checkout`). */
|
|
359
|
+
export function startUpdates({ version, argv = [], env = process.env, packageDir = PACKAGE_DIR, dir = configDir(env), log = stderrLog, check = checkForUpdate } = {}) {
|
|
360
|
+
const install = installOf(packageDir)
|
|
361
|
+
const policy = updatePolicy({ argv, env, install })
|
|
362
|
+
let state = policy.updates === 'on' ? loadState(dir) : {}
|
|
363
|
+
const done =
|
|
364
|
+
policy.updates === 'on'
|
|
365
|
+
? new Promise((resolve) => setImmediate(resolve))
|
|
366
|
+
.then(() => check({ version, dir, env, install }))
|
|
367
|
+
.then((s) => {
|
|
368
|
+
state = s
|
|
369
|
+
if (newer(s.ready?.version, version)) log(`scanruler-mcp ${s.ready.version} is fetched; it starts in place of ${version} next time.`)
|
|
370
|
+
})
|
|
371
|
+
.catch(() => {})
|
|
372
|
+
: Promise.resolve()
|
|
373
|
+
const info = () => ({
|
|
374
|
+
latest: isVersion(state.latest) ? state.latest : null,
|
|
375
|
+
...(policy.handedOffFrom ? { handedOffFrom: policy.handedOffFrom } : {}),
|
|
376
|
+
...(policy.pinned ? { pinned: true } : {}),
|
|
377
|
+
...(policy.updates === 'off' || policy.updates === 'checkout' ? { updates: policy.updates } : {}),
|
|
378
|
+
})
|
|
379
|
+
return { info, done }
|
|
380
|
+
}
|