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/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: JSON.stringify(saved) },
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
+ }