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/README.md +431 -241
- package/bin/scanruler-mcp.js +15 -8
- package/package.json +1 -1
- package/src/commands.json +4435 -2504
- package/src/config.js +38 -6
- package/src/engine.js +69 -0
- package/src/link.js +304 -285
- package/src/main.js +105 -5
- package/src/mcp.js +1155 -290
- package/src/relay.js +218 -217
- package/src/tools.js +207 -14
- package/src/trace.js +55 -0
- 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,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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|