scanruler-mcp 0.1.0 → 0.3.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 +144 -8
- package/package.json +30 -30
- package/src/commands.json +1353 -68
- package/src/config.js +1 -1
- package/src/engine.js +69 -0
- package/src/link.js +10 -2
- package/src/main.js +74 -7
- package/src/mcp.js +434 -30
- package/src/relay.js +2 -1
- package/src/serve.js +1 -1
- package/src/tools.js +41 -1
- package/src/trace.js +55 -0
package/src/tools.js
CHANGED
|
@@ -37,7 +37,43 @@ const imagePath = {
|
|
|
37
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
38
|
}
|
|
39
39
|
|
|
40
|
+
/** How many significant digits a number keeps in an answer, and the finest
|
|
41
|
+
* step it is given to: six digits is a micrometre on a part under a metre
|
|
42
|
+
* and a millionth of a unit vector; past them the page's doubles are
|
|
43
|
+
* noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
|
|
44
|
+
const DIGITS = 6
|
|
45
|
+
const STEP = 1e6
|
|
46
|
+
|
|
47
|
+
const tidy = (v) => {
|
|
48
|
+
const r = Number(v.toPrecision(DIGITS))
|
|
49
|
+
return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An answer as the agent reads it: JSON, every number that is not whole
|
|
53
|
+
* kept to DIGITS significant digits and no finer than a millionth — 1e-17,
|
|
54
|
+
* a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
|
|
55
|
+
* are as they were. An `exact` answer is one whose numbers are handed
|
|
56
|
+
* back as inputs — a plane's frame, say — and keeps every digit:
|
|
57
|
+
* shortened, what is built on them is a hair off what it was meant to
|
|
58
|
+
* meet. */
|
|
59
|
+
export const agentJson = (value, exact = false) =>
|
|
60
|
+
exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
|
|
61
|
+
|
|
40
62
|
/** A command as the MCP tool an agent sees. */
|
|
63
|
+
/** The `wait` a tool that changes the session takes: the server runs every
|
|
64
|
+
* such call as a job (mcp.js), and false answers with the job at once. */
|
|
65
|
+
export const waitIn = {
|
|
66
|
+
type: 'boolean',
|
|
67
|
+
description: 'false: answer at once with { queued: true, job } while the change runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done.',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The `timings` a tool that changes the session takes: the server keeps
|
|
71
|
+
* what the page's answer carries of the build's steps (mcp.js). */
|
|
72
|
+
export const timingsIn = {
|
|
73
|
+
type: 'boolean',
|
|
74
|
+
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).',
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
export function toolFor(command, { outDir }) {
|
|
42
78
|
const input = structuredClone(command.input ?? { type: 'object' })
|
|
43
79
|
input.properties = { ...(input.properties ?? {}) }
|
|
@@ -61,6 +97,10 @@ export function toolFor(command, { outDir }) {
|
|
|
61
97
|
input.properties.path = outPath(outDir)
|
|
62
98
|
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
63
99
|
}
|
|
100
|
+
if (!command.readOnly) {
|
|
101
|
+
input.properties.wait = waitIn
|
|
102
|
+
input.properties.timings = timingsIn
|
|
103
|
+
}
|
|
64
104
|
if (required.length) input.required = required
|
|
65
105
|
else delete input.required
|
|
66
106
|
return {
|
|
@@ -105,7 +145,7 @@ export async function imageContent(result, args, { cwd, outDir }) {
|
|
|
105
145
|
if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
|
|
106
146
|
const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
|
|
107
147
|
return [
|
|
108
|
-
{ type: 'text', text:
|
|
148
|
+
{ type: 'text', text: agentJson(saved) },
|
|
109
149
|
{ type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
|
|
110
150
|
]
|
|
111
151
|
}
|
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
|
+
}
|