scanruler-mcp 0.2.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/mcp.js CHANGED
@@ -4,12 +4,19 @@
4
4
  // scanruler_status, to see whether a ScanRuler page is connected and how to
5
5
  // connect one; scanruler_open, to open one in the user's browser, paired;
6
6
  // scanruler_wait, to wait for it or for it to be idle; and scanruler_batch,
7
- // to run several of the page's tools in one call.
7
+ // to run several of the page's tools in one call. In compact mode, for a
8
+ // model with a small context window, the page's tools are not listed but
9
+ // for those that return a picture, which a batch gives only as text: a
10
+ // fifth of the server's own, scanruler_tools, lists them a line each and
11
+ // gives the definitions of those asked for, and scanruler_batch runs them.
12
+ // Headless (engine.js), the tools are the commands ScanRuler runs in this
13
+ // process with no page, listed in either mode.
8
14
 
9
15
  import { readFileSync } from 'node:fs'
10
16
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
11
17
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
12
- import { agentJson, imageContent, inputFor, saveFileResult, toolFor, toolName } from './tools.js'
18
+ import { agentJson, imageContent, inputFor, saveFileResult, toolFor, toolName, waitIn } from './tools.js'
19
+ import { jsonBytes, replyMeasure } from './trace.js'
13
20
 
14
21
  /** The app's own commands as of this release, so the tools are there before
15
22
  * a page connects — unless a page has connected before, whose list was
@@ -18,15 +25,77 @@ import { agentJson, imageContent, inputFor, saveFileResult, toolFor, toolName }
18
25
  * (tests/mcpCommands.test.ts). */
19
26
  export const SNAPSHOT = JSON.parse(readFileSync(new URL('./commands.json', import.meta.url), 'utf8'))
20
27
 
21
- const INSTRUCTIONS = [
28
+ /** How the page's commands are listed: `full`, each its own tool, or
29
+ * `compact`, read through scanruler_tools. */
30
+ export const TOOL_MODES = ['full', 'compact']
31
+
32
+ /** The most definitions scanruler_tools gives in one answer: twenty are
33
+ * some ten thousand tokens, a small window's fair share. */
34
+ const MAX_DEFINITIONS = 20
35
+
36
+ const ABOUT = [
22
37
  'These tools drive ScanRuler, a measuring app for 3D scans, in the browser tab the user has open: the user watches every step in the viewport and can undo it there.',
23
38
  'Start with scanruler_status. If no ScanRuler page is connected, call scanruler_open: it opens ScanRuler in the user’s browser, paired with this server, and waits for it. If that leaves it unconnected, tell the user what howToConnect says, then call scanruler_wait.',
39
+ ]
40
+ const WORKING = [
24
41
  'Read session_state before acting and after: the scan’s bounding box says where the part lies, the elements and dimensions what is measured. Lengths are millimetres, angles degrees; answers give numbers to six significant digits.',
25
42
  'A place on the scan is { point: [x, y, z] } (the nearest scan vertex is taken) or { vertex: n }. To fit a feature, aim at its middle, not at an edge.',
26
43
  'Every tool that changes the session is one step of the undo history; history_undo takes the last back. report_get gives the report as JSON (format "json") or as the text the panel copies (format "text").',
27
- 'view_render shows you the 3D view as a picture; view_set turns it to a standard view or fits it.',
28
- 'scanruler_batch runs several tools in one call, in order — many scan_nearest or element_fit calls, say — and stops at the first that is refused unless told to go on.',
29
- ].join(' ')
44
+ ]
45
+ const INSTRUCTIONS = {
46
+ full: [
47
+ ...ABOUT,
48
+ ...WORKING,
49
+ 'view_render shows you the 3D view as a picture; view_set turns it to a standard view or fits it.',
50
+ 'scanruler_batch runs several tools in one call, in order — many scan_nearest or element_fit calls, say — and stops at the first that is refused unless told to go on.',
51
+ ].join(' '),
52
+ compact: [
53
+ ...ABOUT,
54
+ 'ScanRuler’s own tools are not listed one by one, but for those that return a picture. scanruler_tools lists them all, a name and a line each; scanruler_tools with names gives those tools’ full definitions. Read a tool’s definition before its first use, and give it the arguments its inputSchema asks for.',
55
+ 'Run the tools through scanruler_batch — { "calls": [{ "tool": "session_state", "arguments": {} }] }; a single tool is a batch of one, and several run in order. Each result is { tool, ok, result } or { tool, ok: false, error: { code, message } }: the code is the page’s own (invalid_input, no_scan, busy, invalid_state, not_found …) and the message says what was wrong, so retry a refused call with its arguments corrected.',
56
+ ...WORKING,
57
+ 'The tools that return a picture — view_render, which pictures the 3D view, among them — are listed as tools of their own: call them directly to see the picture. In a batch a picture comes back as text only, and is written to a file when its call gives path. view_set turns the view.',
58
+ ].join(' '),
59
+ }
60
+
61
+ /** A command in a line, for scanruler_tools' list: its title, or the first
62
+ * sentence of its description. */
63
+ function summary(command) {
64
+ if (command.title) return command.title
65
+ const description = String(command.description ?? '').replace(/\s+/g, ' ').trim()
66
+ return /^.*?[.!?](?= |$)/.exec(description)?.[0] ?? description
67
+ }
68
+
69
+ /** What the server says it is when it runs ScanRuler itself, headless. */
70
+ const HEADLESS_ABOUT = [
71
+ 'These tools drive ScanRuler headless: the app runs inside this server, with no browser, no scan and no viewport — nobody watches it, and nothing is drawn. scanruler_status says so (backend: "headless").',
72
+ ]
73
+ const HEADLESS_WORKING = [
74
+ 'Lengths are millimetres, angles degrees; answers give numbers to six significant digits. Every tool that changes the session is one step of the undo history; history_undo takes the last back. Read session_state before acting and after.',
75
+ ]
76
+ const HEADLESS_INSTRUCTIONS = {
77
+ full: [
78
+ ...HEADLESS_ABOUT,
79
+ 'The tools listed are those that run so: modelling solids feature by feature with parameters, describing and checking the bodies, notes, and STEP and STL files read and written on this computer. A tool that needs the page — a scan, a picture, the browser — is not listed, and answers unavailable if called; it needs this server started in page mode, paired with a ScanRuler tab.',
80
+ ...HEADLESS_WORKING,
81
+ 'scanruler_batch runs several tools in one call, in order, and stops at the first that is refused unless told to go on.',
82
+ ].join(' '),
83
+ compact: [
84
+ ...HEADLESS_ABOUT,
85
+ 'ScanRuler’s tools are those that run so — modelling solids feature by feature with parameters, describing and checking the bodies, notes, and STEP and STL files read and written on this computer — and are not listed one by one. scanruler_tools lists them, a name and a line each; scanruler_tools with names gives those tools’ full definitions. Read a tool’s definition before its first use, and give it the arguments its inputSchema asks for. A tool that needs the page — a scan, a picture, the browser — is not among them, and answers unavailable if called; it needs this server started in page mode, paired with a ScanRuler tab.',
86
+ 'Run the tools through scanruler_batch — { "calls": [{ "tool": "session_state", "arguments": {} }] }; a single tool is a batch of one, and several run in order. Each result is { tool, ok, result } or { tool, ok: false, error: { code, message } }: the message says what was wrong, so retry a refused call with its arguments corrected.',
87
+ ...HEADLESS_WORKING,
88
+ ].join(' '),
89
+ }
90
+
91
+ /** How many jobs the server keeps answers of, the oldest finished let go
92
+ * first. */
93
+ const JOBS_KEPT = 50
94
+
95
+ /** A change answered directly that took longer than this has likely
96
+ * outlived its client's wait (Claude Desktop's is a minute): its answer
97
+ * counts as not yet seen, for scanruler_wait { for: "idle" } to give. */
98
+ const SEEN_WITHIN_MS = 55_000
30
99
 
31
100
  const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
32
101
  const failure = (e) => {
@@ -48,7 +117,11 @@ const failure = (e) => {
48
117
  * openPage?: () => Promise<boolean>,
49
118
  * openGraceMs?: number,
50
119
  * openWaitMs?: number,
51
- * }} options — `openPage` opens the pairing link in the user's browser
120
+ * tools?: 'full' | 'compact',
121
+ * trace?: ReturnType<typeof import('./trace.js').createTrace>,
122
+ * }} options — `tools` is how the page's commands are listed (TOOL_MODES).
123
+ * `trace`, when given, gets a line per call (trace.js).
124
+ * `openPage` opens the pairing link in the user's browser
52
125
  * (browser.js), true when it could; scanruler_open first gives a tab that
53
126
  * is already paired `openGraceMs` to connect by itself — the page tries
54
127
  * again at most ten seconds apart — and then waits `openWaitMs` for the
@@ -67,10 +140,14 @@ export function createMcpServer({
67
140
  openPage = async () => false,
68
141
  openGraceMs = 11_000,
69
142
  openWaitMs = 40_000,
143
+ tools: toolMode = 'full',
144
+ trace = null,
70
145
  }) {
146
+ const headless = link.backend === 'headless'
147
+ const compact = toolMode === 'compact'
71
148
  const server = new Server(
72
149
  { name: 'scanruler', title: 'ScanRuler', version },
73
- { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS },
150
+ { capabilities: { tools: { listChanged: true } }, instructions: (headless ? HEADLESS_INSTRUCTIONS : INSTRUCTIONS)[compact ? 'compact' : 'full'] },
74
151
  )
75
152
 
76
153
  // The last connected page's commands, for listing before a page is back:
@@ -90,7 +167,70 @@ export function createMcpServer({
90
167
  }
91
168
  }
92
169
 
170
+ // ---- jobs ---------------------------------------------------------------
171
+ // Every call that changes the session is a job, its answer kept: one
172
+ // started with wait: false answers at once with the job, and one the
173
+ // client gave up waiting for keeps running and keeps its reply — the
174
+ // status lists both, and scanruler_wait { for: "job" } gives the answer.
175
+ const jobs = new Map()
176
+ let nextJob = 1
177
+ // `ctx` is the call's own, for the trace: the job is named on the call's
178
+ // line, and one answered at once (queued) gets a line when it is done.
179
+ const startJob = (tool, run, ctx = null, queued = false) => {
180
+ const job = { job: `j${nextJob++}`, tool, state: 'running', started: Date.now(), ms: null, reply: null }
181
+ if (ctx) Object.assign(ctx.fields, { job: job.job, ...(queued ? { queued: true } : {}) })
182
+ const finished = (reply) => {
183
+ job.state = reply.isError ? 'failed' : 'done'
184
+ job.ms = Date.now() - job.started
185
+ job.reply = reply
186
+ // Answered to the client: seen, unless queued or likely timed out.
187
+ job.told = !queued && job.ms < SEEN_WITHIN_MS
188
+ if (queued && trace) trace.write({ ev: 'job', job: job.job, call: ctx?.call ?? null, tool, t: job.started, ms: job.ms, ...replyMeasure(reply) })
189
+ return reply
190
+ }
191
+ job.done = run().then(finished, (e) => finished(failure(e)))
192
+ jobs.set(job.job, job)
193
+ // The newest kept, but never one still running.
194
+ for (const [id, j] of jobs) {
195
+ if (jobs.size <= JOBS_KEPT) break
196
+ if (j.state !== 'running') jobs.delete(id)
197
+ }
198
+ return job
199
+ }
200
+ const jobBrief = (j) => ({ job: j.job, tool: j.tool, state: j.state, ...(j.ms !== null ? { ms: j.ms } : { runningMs: Date.now() - j.started }) })
201
+ /** A job's answer as scanruler_wait gives it: the tool's own answer
202
+ * under result, or its refusal under error. */
203
+ const jobAnswer = (j) => {
204
+ if (!j.reply) return jobBrief(j)
205
+ const body = j.reply.content.find((x) => x.type === 'text')?.text ?? ''
206
+ if (j.reply.isError) {
207
+ const m = /^([a-z_]+): ([\s\S]*)$/.exec(body)
208
+ return { ...jobBrief(j), error: m ? { code: m[1], message: m[2] } : { code: 'failed', message: body } }
209
+ }
210
+ let result
211
+ try {
212
+ result = JSON.parse(body)
213
+ } catch {
214
+ result = body
215
+ }
216
+ return { ...jobBrief(j), result }
217
+ }
218
+
219
+ /** Whether the tab is hidden now — null with no page, or headless. */
220
+ const hiddenNow = () => (link.connected && link.page && !headless ? (link.page.hidden ?? false) : null)
221
+ const runningJobs = () => [...jobs.values()].filter((j) => j.state === 'running').length
222
+
223
+ /** A finished job as scanruler_wait { for: "idle" } lists it: a failed
224
+ * one with its error, a batch with the call it stopped at. */
225
+ const finishedOut = (j) => {
226
+ const answer = jobAnswer(j)
227
+ if (j.state === 'failed') return { ...jobBrief(j), error: answer.error }
228
+ const failed = j.tool === 'scanruler_batch' ? answer.result?.failed : undefined
229
+ return { ...jobBrief(j), ...(failed ? { failed } : {}) }
230
+ }
231
+
93
232
  const status = () => ({
233
+ backend: headless ? 'headless' : 'page',
94
234
  connected: link.connected,
95
235
  page: link.page
96
236
  ? {
@@ -99,18 +239,21 @@ export function createMcpServer({
99
239
  origin: link.page.origin,
100
240
  plugins: link.page.plugins ?? [],
101
241
  viewport: link.page.viewport ?? null,
242
+ hidden: link.page.hidden ?? null,
102
243
  commands: link.page.commands.length,
103
244
  }
104
245
  : null,
105
246
  server: serverInfo(),
106
- ...(link.connected ? {} : { howToConnect: connectHelp() }),
247
+ ...(link.connected || headless ? {} : { howToConnect: connectHelp() }),
248
+ ...(link.page?.hidden ? { hiddenNote: 'The ScanRuler tab is hidden — in the background, its window covered or minimised, or the screen locked. Changes still run; pictures and anything that waits on the screen are slower, and a browser that unloads background tabs (Chrome’s Memory Saver) may close it: add the site to "Always keep these sites active".' } : {}),
249
+ ...(jobs.size > 0 ? { jobs: [...jobs.values()].reverse().map(jobBrief) } : {}),
107
250
  })
108
251
 
109
252
  const own = [
110
253
  {
111
254
  name: 'scanruler_status',
112
255
  title: 'ScanRuler connection status',
113
- description: 'Whether a ScanRuler page is connected to this agent, which version and plugins it has, and — when none is — how the user connects one.',
256
+ description: 'Whether a ScanRuler page is connected to this agent, which version and plugins it has, whether its tab is hidden (page.hidden), and — when none is — how the user connects one. backend says whether ScanRuler runs in a tab (page) or in this server (headless). jobs lists the latest calls that change the session, newest first, each running, done or failed — a call whose answer you did not get is among them; scanruler_wait { for: "job", job } gives its answer.',
114
257
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
115
258
  annotations: { readOnlyHint: true, openWorldHint: false },
116
259
  call: async () => text(status()),
@@ -122,6 +265,7 @@ export function createMcpServer({
122
265
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
123
266
  annotations: { readOnlyHint: false, openWorldHint: false },
124
267
  call: async () => {
268
+ if (headless) return text({ ...status(), opened: false, note: 'This server runs ScanRuler headless, in this process: there is no page to open.' })
125
269
  // A tab that was paired before is likely open and on its way back.
126
270
  if (link.connected || (await link.waitForPage(openGraceMs))) return text({ ...status(), opened: false })
127
271
  const opened = await openPage().catch(() => false)
@@ -132,25 +276,48 @@ export function createMcpServer({
132
276
  {
133
277
  name: 'scanruler_wait',
134
278
  title: 'Wait for ScanRuler',
135
- description: 'Wait until a ScanRuler page is connected (for: "page", the default), or until it is also done with what it is busy with (for: "idle"). Returns the status; connected is false if the time ran out.',
279
+ description: 'Wait until a ScanRuler page is connected (for: "page", the default), or until it is also done with what it is busy with and every change still running has finished (for: "idle" — then finished lists the changes done or failed since the last such answer, a failed one with its error), or until a job is done (for: "job" with job — a call started with wait: false, or one whose answer did not come; scanruler_status lists them). Returns the status, or for a job its answer: { job, tool, state, ms, result } or { …, error: { code, message } } — state running if the time ran out first. connected is false if the time ran out waiting for a page.',
136
280
  inputSchema: {
137
281
  type: 'object',
138
282
  properties: {
139
- for: { type: 'string', enum: ['page', 'idle'], description: 'page (the default) or idle.' },
283
+ for: { type: 'string', enum: ['page', 'idle', 'job'], description: 'page (the default), idle, or job.' },
284
+ job: { type: 'string', minLength: 1, description: 'The job to wait for, with for: "job" — as a call with wait: false or scanruler_status gave it.' },
140
285
  timeoutSeconds: { type: 'number', exclusiveMinimum: 0, maximum: 600, description: 'How long to wait at most, seconds — 120 by default.' },
141
286
  },
142
287
  additionalProperties: false,
143
288
  },
144
289
  annotations: { readOnlyHint: true, openWorldHint: false },
145
- call: async (args) => {
290
+ call: async (args, ctx) => {
291
+ if (ctx) ctx.fields.wait = args?.for ?? (args?.job !== undefined ? 'job' : 'page')
146
292
  const until = Date.now() + 1000 * (args?.timeoutSeconds ?? 120)
293
+ if (args?.for === 'job' || args?.job !== undefined) {
294
+ const j = jobs.get(String(args?.job ?? ''))
295
+ if (!j) return failure({ code: 'not_found', message: `There is no job "${args?.job ?? ''}" — scanruler_status lists the latest.` })
296
+ let timer
297
+ await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
298
+ clearTimeout(timer)
299
+ if (j.state !== 'running') j.told = true
300
+ return text(jobAnswer(j))
301
+ }
147
302
  if (!(await link.waitForPage(Math.max(0, until - Date.now())))) return text(status())
148
303
  if (args?.for === 'idle') {
304
+ // The changes still running first — their answers are what idle
305
+ // waits for — then the page's own work.
306
+ for (const j of [...jobs.values()].filter((x) => x.state === 'running')) {
307
+ let timer
308
+ await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
309
+ clearTimeout(timer)
310
+ }
149
311
  while (Date.now() < until) {
150
312
  const state = await link.run('session.state', {}).catch(() => null)
151
313
  if (state && state.busy === null && state.running === null) break
152
314
  await new Promise((r) => setTimeout(r, 500))
153
315
  }
316
+ // What finished since it was last said: each job once, a failed
317
+ // one with its error.
318
+ const finished = [...jobs.values()].filter((j) => j.state !== 'running' && !j.told)
319
+ for (const j of finished) j.told = true
320
+ return text({ ...status(), finished: finished.map(finishedOut) })
154
321
  }
155
322
  return text(status())
156
323
  },
@@ -158,11 +325,17 @@ export function createMcpServer({
158
325
  ]
159
326
 
160
327
  let listed = ''
161
- const listNow = () => [...own.map(({ call: _call, ...tool }) => tool), ...commands().map((c) => toolFor(c, { outDir }))]
328
+ const definition = ({ call: _call, ...tool }) => tool
329
+ // Compact, the list is the server's own tools and the page's that return
330
+ // a picture — a batch gives a picture as its text, so those are called
331
+ // directly; scanruler_tools reads commands() when it is called.
332
+ const shown = (c) => !compact || Boolean(c.returnsImage)
333
+ const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir }))]
162
334
 
163
335
  server.setRequestHandler(ListToolsRequestSchema, async () => {
164
336
  const tools = listNow()
165
337
  listed = JSON.stringify(tools)
338
+ trace?.write({ ev: 'list', t: trace.now(), tools: tools.length, bytes: Buffer.byteLength(listed, 'utf8') })
166
339
  return { tools }
167
340
  })
168
341
 
@@ -182,17 +355,27 @@ export function createMcpServer({
182
355
 
183
356
  /** One of the page's tools run on the page: the result as MCP content,
184
357
  * or a failure. */
185
- const runPageTool = async (name, args, onProgress) => {
358
+ const runPageTool = async (name, args, onProgress, { timings = false, ms = false } = {}) => {
186
359
  const command = commands().find((c) => toolName(c) === name)
360
+ const needsPage = !command && headless ? link.pageCommand(name) : null
361
+ if (needsPage) return failure({ code: 'unavailable', message: `${name} needs the ScanRuler page — a scan, the viewport or the browser — and this server runs ScanRuler headless, with none. Start scanruler-mcp in page mode (without --engine or --headless) and pair it with a ScanRuler tab for it.` })
187
362
  if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
188
363
  if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
189
364
  return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
190
365
  }
191
366
  try {
192
367
  const { input, binaryKey } = await inputFor(command, args, { cwd })
193
- const result = await link.run(command.name, input, binaryKey, onProgress)
368
+ const started = Date.now()
369
+ let result = await link.run(command.name, input, binaryKey, onProgress)
370
+ if (ms && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, ms: Date.now() - started }
194
371
  const image = command.returnsImage ? await imageContent(result, args, { cwd, outDir }) : null
195
372
  if (image) return { content: image }
373
+ // Which steps a change's build played, and how long each took, only
374
+ // for a batch asked for its timings.
375
+ if (!timings && result?.steps?.played) {
376
+ const { played: _played, ...steps } = result.steps
377
+ result = { ...result, steps }
378
+ }
196
379
  return text(await saveFileResult(result, args, { cwd, outDir }), Boolean(command.exact))
197
380
  } catch (e) {
198
381
  return failure(e)
@@ -207,7 +390,7 @@ export function createMcpServer({
207
390
  name: 'scanruler_batch',
208
391
  title: 'Run several ScanRuler tools',
209
392
  description:
210
- 'Run several of the ScanRuler tools in one call, in order, each with its own arguments — a run of scan_nearest, element_fit or section_get calls, say. Returns results, one per call in order: { tool, ok, result } or { tool, ok: false, error: { code, message } }. The first refused stops the rest (stopped says which index) unless stopOnError is false. Each call that changes the session is still its own undo step. A tool that returns a picture gives its text here, the picture written to a file only if its call gave path. The server’s own tools (scanruler_status, _open, _wait, _batch) cannot be batched.',
393
+ 'Run several of the ScanRuler tools in one call, in order, each with its own arguments — a run of scan_nearest, element_fit or section_get calls, say. Returns results, one per call in order: { tool, ok, result } or { tool, ok: false, error: { code, message } }. The first refused stops the rest (stopped says which index) unless stopOnError is false — and then, with rollback (the default), every change the batch made before it is undone, so the batch stands or falls whole: the answer names failed: { index, tool, error } and undone (the steps taken back, newest first). Each call that changes the session is still its own undo step. timings: true gives each call its ms and the batch total_ms, and a change\'s build the steps it played with their ms (result.steps.played). wait: false answers at once with { queued, job }; scanruler_wait { for: "job", job } gives the batch\'s answer. A tool that returns a picture gives its text here, the picture written to a file only if its call gave path. The server’s own tools (scanruler_status, _open, _wait, _batch) cannot be batched.',
211
394
  inputSchema: {
212
395
  type: 'object',
213
396
  properties: {
@@ -227,53 +410,185 @@ export function createMcpServer({
227
410
  },
228
411
  },
229
412
  stopOnError: { type: 'boolean', description: 'Stop at the first refused call (the default), or run every call and report each.' },
413
+ rollback: { type: 'boolean', description: 'When the batch stops at a refused call, undo what it changed before it (the default) — or leave those changes standing (false).' },
414
+ timings: { type: 'boolean', description: 'Each call’s ms, the batch’s total_ms, and the steps a change’s build played with their ms.' },
415
+ wait: waitIn,
230
416
  },
231
417
  required: ['calls'],
232
418
  additionalProperties: false,
233
419
  },
234
420
  annotations: { readOnlyHint: false, openWorldHint: false },
235
- call: async (args) => {
421
+ call: async (args, ctx) => {
236
422
  const calls = Array.isArray(args?.calls) ? args.calls : []
237
423
  if (calls.length === 0) return failure({ code: 'invalid_input', message: 'calls: give at least one call.' })
238
- const stopOnError = args?.stopOnError !== false
239
- const results = []
240
- let stopped
241
- // A batch holding an exact answer (a plane's frame, say) keeps
242
- // every answer exact: the rounding is one pass over the whole.
243
- let exact = false
244
- for (let i = 0; i < calls.length; i++) {
245
- const c = calls[i] ?? {}
246
- const tool = String(c.tool ?? '')
247
- const one = own.some((t) => t.name === tool)
248
- ? failure({ code: 'invalid_input', message: `${tool} is the server’s own and is not batched — call it on its own.` })
249
- : await runPageTool(tool, c.arguments ?? {}, undefined)
250
- if (!one.isError && commands().find((x) => toolName(x) === tool)?.exact) exact = true
251
- const body = one.content.find((x) => x.type === 'text')?.text ?? ''
252
- if (one.isError) {
253
- const m = /^([a-z_]+): ([\s\S]*)$/.exec(body)
254
- results.push({ tool, ok: false, error: m ? { code: m[1], message: m[2] } : { code: 'failed', message: body } })
255
- if (stopOnError) {
256
- stopped = i
257
- break
258
- }
259
- } else {
260
- let parsed
261
- try {
262
- parsed = JSON.parse(body)
263
- } catch {
264
- parsed = body
265
- }
266
- results.push({ tool, ok: true, result: parsed })
267
- }
268
- }
269
- return text({ results, ran: results.length, of: calls.length, ...(stopped !== undefined ? { stopped } : {}) }, exact)
424
+ if (ctx) ctx.fields.calls = calls.length
425
+ const job = startJob('scanruler_batch', () => runBatch(calls, args, ctx), ctx, args?.wait === false)
426
+ return args?.wait === false ? text({ queued: true, job: job.job, tool: 'scanruler_batch' }) : job.done
270
427
  },
271
428
  })
272
429
 
430
+ /** The batch's calls run in order — see scanruler_batch. */
431
+ const runBatch = async (calls, args, ctx = null) => {
432
+ const stopOnError = args?.stopOnError !== false
433
+ const timings = args?.timings === true
434
+ const commandOf = (tool) => commands().find((x) => toolName(x) === tool)
435
+ // Where the history stood, to go back to when a call is refused — only
436
+ // when a call may change something, and a page that cannot mark its
437
+ // history simply has its batch left as it went.
438
+ let checkpoint = null
439
+ if (args?.rollback !== false && stopOnError && calls.some((c) => commandOf(String(c?.tool ?? '')) && !commandOf(String(c?.tool ?? '')).readOnly)) {
440
+ checkpoint = await link
441
+ .run('history.checkpoint', {})
442
+ .then((r) => r?.checkpoint ?? null)
443
+ .catch(() => null)
444
+ }
445
+ const results = []
446
+ let stopped
447
+ let failed
448
+ // A batch holding an exact answer (a plane's frame, say) keeps
449
+ // every answer exact: the rounding is one pass over the whole.
450
+ let exact = false
451
+ const started = Date.now()
452
+ for (let i = 0; i < calls.length; i++) {
453
+ const c = calls[i] ?? {}
454
+ const tool = String(c.tool ?? '')
455
+ const at = Date.now()
456
+ const { wait: _wait, timings: _timings, ...callArgs } = c.arguments ?? {}
457
+ const hiddenAt = hiddenNow()
458
+ const one = own.some((t) => t.name === tool)
459
+ ? failure({ code: 'invalid_input', message: `${tool} is the server’s own and is not batched — call it on its own.` })
460
+ : await runPageTool(tool, callArgs, undefined, { timings })
461
+ if (ctx && trace) {
462
+ trace.write({ ev: 'entry', call: ctx.call, index: i, t: at, tool, ms: Date.now() - at, in: jsonBytes(callArgs), ...replyMeasure(one), hidden: [hiddenAt, hiddenNow()], ...(trace.args ? { args: callArgs } : {}) })
463
+ }
464
+ const ms = timings ? { ms: Date.now() - at } : {}
465
+ if (!one.isError && commandOf(tool)?.exact) exact = true
466
+ const body = one.content.find((x) => x.type === 'text')?.text ?? ''
467
+ if (one.isError) {
468
+ const m = /^([a-z_]+): ([\s\S]*)$/.exec(body)
469
+ const error = m ? { code: m[1], message: m[2] } : { code: 'failed', message: body }
470
+ results.push({ tool, ok: false, error, ...ms })
471
+ if (stopOnError) {
472
+ stopped = i
473
+ failed = { index: i, tool, error }
474
+ break
475
+ }
476
+ } else {
477
+ let parsed
478
+ try {
479
+ parsed = JSON.parse(body)
480
+ } catch {
481
+ parsed = body
482
+ }
483
+ results.push({ tool, ok: true, result: parsed, ...ms })
484
+ }
485
+ }
486
+ // Refused: what the batch did before it taken back, newest first.
487
+ let undone
488
+ let rollbackError
489
+ if (failed && checkpoint) {
490
+ try {
491
+ undone = (await link.run('history.restore', { checkpoint }))?.undone ?? []
492
+ } catch (e) {
493
+ rollbackError = `${e?.code ?? 'failed'}: ${e?.message ?? String(e)}`
494
+ }
495
+ }
496
+ return text(
497
+ {
498
+ results,
499
+ ran: results.length,
500
+ of: calls.length,
501
+ ...(stopped !== undefined ? { stopped } : {}),
502
+ ...(failed ? { failed, ...(checkpoint ? { undone: undone ?? [] } : {}), ...(rollbackError ? { rollbackError } : {}) } : {}),
503
+ ...(timings ? { total_ms: Date.now() - started } : {}),
504
+ },
505
+ exact,
506
+ )
507
+ }
508
+
509
+ // The page's tools for a client that does not list them: a line each,
510
+ // or the definitions of some — each as the full list would give it.
511
+ if (compact) {
512
+ own.push({
513
+ name: 'scanruler_tools',
514
+ title: 'List the ScanRuler tools',
515
+ description: `The ScanRuler tools, which this server lists one by one only when they return a picture. With no arguments: every tool’s name and what it does, a line each. With names: those tools’ full definitions — description and inputSchema — up to ${MAX_DEFINITIONS} at a time; read a tool’s before its first use. Run them with scanruler_batch, a single tool as a batch of one. Names it does not know come back in unknown. The list is the connected page’s, so it can change when a page connects.`,
516
+ inputSchema: {
517
+ type: 'object',
518
+ properties: {
519
+ names: {
520
+ type: 'array',
521
+ items: { type: 'string', minLength: 1 },
522
+ maxItems: MAX_DEFINITIONS,
523
+ description: 'Tool names, as the list gives them — element_fit, scan_nearest. Leave out for the list.',
524
+ },
525
+ },
526
+ additionalProperties: false,
527
+ },
528
+ annotations: { readOnlyHint: true, openWorldHint: false },
529
+ call: async (args) => {
530
+ const asked = typeof args?.names === 'string' ? args.names.split(/[\s,]+/) : Array.isArray(args?.names) ? args.names.map(String) : []
531
+ const names = [...new Set(asked.filter(Boolean))]
532
+ const list = commands()
533
+ if (names.length === 0) {
534
+ return text({
535
+ count: list.length,
536
+ tools: Object.fromEntries(list.map((c) => [toolName(c), summary(c)])),
537
+ next: 'scanruler_tools with names gives a tool’s definition; scanruler_batch runs it.',
538
+ })
539
+ }
540
+ if (names.length > MAX_DEFINITIONS) {
541
+ return failure({ code: 'invalid_input', message: `names: at most ${MAX_DEFINITIONS} at a time — ask for the rest in another call.` })
542
+ }
543
+ const found = []
544
+ const unknown = []
545
+ for (const name of names) {
546
+ const command = list.find((c) => toolName(c) === name)
547
+ const mine = own.find((t) => t.name === name)
548
+ if (command) found.push(toolFor(command, { outDir }))
549
+ else if (mine) found.push(definition(mine))
550
+ else unknown.push(name)
551
+ }
552
+ // Exact: a schema's numbers are limits and defaults, not readings.
553
+ return text({ tools: found, unknown }, true)
554
+ },
555
+ })
556
+ }
557
+
558
+ // Each call traced as one line when it is answered — what the client
559
+ // waited for — with the tab's hidden state at its start and end and the
560
+ // jobs running when it came; a batch's entries and a queued job's end get
561
+ // lines of their own.
562
+ let callNo = 0
273
563
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
564
+ if (!trace) return dispatch(request, extra, null)
565
+ const { name, arguments: args } = request.params
566
+ const ctx = { call: ++callNo, fields: {} }
567
+ const t = trace.now()
568
+ const hiddenAt = hiddenNow()
569
+ const running = runningJobs()
570
+ const reply = await dispatch(request, extra, ctx)
571
+ trace.write({
572
+ ev: 'call',
573
+ call: ctx.call,
574
+ t,
575
+ tool: name,
576
+ ms: trace.now() - t,
577
+ in: jsonBytes(args ?? {}),
578
+ ...replyMeasure(reply),
579
+ hidden: [hiddenAt, hiddenNow()],
580
+ ...(running ? { running } : {}),
581
+ ...ctx.fields,
582
+ ...(trace.args ? { args: args ?? {} } : {}),
583
+ })
584
+ return reply
585
+ })
586
+
587
+ /** A tool call answered — see the handler above. */
588
+ const dispatch = async (request, extra, ctx) => {
274
589
  const { name, arguments: args } = request.params
275
590
  const mine = own.find((t) => t.name === name)
276
- if (mine) return mine.call(args)
591
+ if (mine) return mine.call(args, ctx)
277
592
  // Progress lines from the page, for a client that asked for them.
278
593
  const token = request.params._meta?.progressToken
279
594
  let step = 0
@@ -283,8 +598,18 @@ export function createMcpServer({
283
598
  : (line) => {
284
599
  extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
285
600
  }
286
- return runPageTool(name, args, onProgress)
287
- })
601
+ // A change runs as a job: answered when done, or at once with the job
602
+ // for wait: false — and kept either way, for a client that stops
603
+ // waiting before it is done.
604
+ const command = commands().find((c) => toolName(c) === name)
605
+ if (!command || command.readOnly) return runPageTool(name, args, onProgress)
606
+ const { wait, timings, ...rest } = args ?? {}
607
+ const job = startJob(name, () => runPageTool(name, rest, onProgress, { timings: timings === true, ms: timings === true }), ctx, wait === false)
608
+ return wait === false ? text({ queued: true, job: job.job, tool: name }) : job.done
609
+ }
610
+
611
+ trace?.write({ ev: 'start', t: trace.now(), version, backend: headless ? 'headless' : 'page', tools: toolMode, pid: process.pid, args: trace.args })
612
+ link.on?.('visibility', (hidden) => trace?.write({ ev: 'visibility', t: trace.now(), hidden }))
288
613
 
289
614
  return server
290
615
  }
package/src/relay.js CHANGED
@@ -80,6 +80,7 @@ export class PeerLink extends Asker {
80
80
  this.page = page
81
81
  if (page && !was) this.emit('connected', page)
82
82
  else if (!page && was) this.emit('disconnected')
83
+ else if (page && (page.hidden ?? false) !== (was.hidden ?? false)) this.emit('visibility', page.hidden === true)
83
84
  else if (page) this.emit('commands', page.commands)
84
85
  }
85
86
 
@@ -159,7 +160,7 @@ export class SharedLink extends EventEmitter {
159
160
 
160
161
  /** Pass on a link's page events while `active()` says it is the one in use. */
161
162
  #forward(link, active) {
162
- for (const event of ['connected', 'disconnected', 'commands']) {
163
+ for (const event of ['connected', 'disconnected', 'commands', 'visibility']) {
163
164
  link.on(event, (...args) => {
164
165
  if (active()) this.emit(event, ...args)
165
166
  })
package/src/tools.js CHANGED
@@ -60,6 +60,20 @@ export const agentJson = (value, exact = false) =>
60
60
  exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
61
61
 
62
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
+
63
77
  export function toolFor(command, { outDir }) {
64
78
  const input = structuredClone(command.input ?? { type: 'object' })
65
79
  input.properties = { ...(input.properties ?? {}) }
@@ -83,6 +97,10 @@ export function toolFor(command, { outDir }) {
83
97
  input.properties.path = outPath(outDir)
84
98
  description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
85
99
  }
100
+ if (!command.readOnly) {
101
+ input.properties.wait = waitIn
102
+ input.properties.timings = timingsIn
103
+ }
86
104
  if (required.length) input.required = required
87
105
  else delete input.required
88
106
  return {