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/mcp.js CHANGED
@@ -1,14 +1,22 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // The MCP side: the tools an agent lists and calls. Each of the page's
3
- // commands is a tool (tools.js); two more are this server's own —
3
+ // commands is a tool (tools.js); four more are this server's own —
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
- // and scanruler_wait, to wait for it or for it to be idle.
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. 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.
7
14
 
8
15
  import { readFileSync } from 'node:fs'
9
16
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
10
17
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
11
- import { 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'
12
20
 
13
21
  /** The app's own commands as of this release, so the tools are there before
14
22
  * a page connects — unless a page has connected before, whose list was
@@ -17,16 +25,79 @@ import { imageContent, inputFor, saveFileResult, toolFor, toolName } from './too
17
25
  * (tests/mcpCommands.test.ts). */
18
26
  export const SNAPSHOT = JSON.parse(readFileSync(new URL('./commands.json', import.meta.url), 'utf8'))
19
27
 
20
- export 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 = [
21
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.',
22
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.',
23
- '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.',
39
+ ]
40
+ const WORKING = [
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.',
24
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.',
25
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").',
26
- 'view_render shows you the 3D view as a picture; view_set turns it to a standard view or fits it.',
27
- ].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
+ }
28
60
 
29
- const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] })
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
99
+
100
+ const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
30
101
  const failure = (e) => {
31
102
  const code = e?.code ?? 'failed'
32
103
  const message = e?.message ?? String(e)
@@ -46,7 +117,11 @@ const failure = (e) => {
46
117
  * openPage?: () => Promise<boolean>,
47
118
  * openGraceMs?: number,
48
119
  * openWaitMs?: number,
49
- * }} 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
50
125
  * (browser.js), true when it could; scanruler_open first gives a tab that
51
126
  * is already paired `openGraceMs` to connect by itself — the page tries
52
127
  * again at most ten seconds apart — and then waits `openWaitMs` for the
@@ -65,10 +140,14 @@ export function createMcpServer({
65
140
  openPage = async () => false,
66
141
  openGraceMs = 11_000,
67
142
  openWaitMs = 40_000,
143
+ tools: toolMode = 'full',
144
+ trace = null,
68
145
  }) {
146
+ const headless = link.backend === 'headless'
147
+ const compact = toolMode === 'compact'
69
148
  const server = new Server(
70
149
  { name: 'scanruler', title: 'ScanRuler', version },
71
- { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS },
150
+ { capabilities: { tools: { listChanged: true } }, instructions: (headless ? HEADLESS_INSTRUCTIONS : INSTRUCTIONS)[compact ? 'compact' : 'full'] },
72
151
  )
73
152
 
74
153
  // The last connected page's commands, for listing before a page is back:
@@ -88,7 +167,70 @@ export function createMcpServer({
88
167
  }
89
168
  }
90
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
+
91
232
  const status = () => ({
233
+ backend: headless ? 'headless' : 'page',
92
234
  connected: link.connected,
93
235
  page: link.page
94
236
  ? {
@@ -97,18 +239,21 @@ export function createMcpServer({
97
239
  origin: link.page.origin,
98
240
  plugins: link.page.plugins ?? [],
99
241
  viewport: link.page.viewport ?? null,
242
+ hidden: link.page.hidden ?? null,
100
243
  commands: link.page.commands.length,
101
244
  }
102
245
  : null,
103
246
  server: serverInfo(),
104
- ...(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) } : {}),
105
250
  })
106
251
 
107
252
  const own = [
108
253
  {
109
254
  name: 'scanruler_status',
110
255
  title: 'ScanRuler connection status',
111
- 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.',
112
257
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
113
258
  annotations: { readOnlyHint: true, openWorldHint: false },
114
259
  call: async () => text(status()),
@@ -120,6 +265,7 @@ export function createMcpServer({
120
265
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
121
266
  annotations: { readOnlyHint: false, openWorldHint: false },
122
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.' })
123
269
  // A tab that was paired before is likely open and on its way back.
124
270
  if (link.connected || (await link.waitForPage(openGraceMs))) return text({ ...status(), opened: false })
125
271
  const opened = await openPage().catch(() => false)
@@ -130,25 +276,48 @@ export function createMcpServer({
130
276
  {
131
277
  name: 'scanruler_wait',
132
278
  title: 'Wait for ScanRuler',
133
- 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.',
134
280
  inputSchema: {
135
281
  type: 'object',
136
282
  properties: {
137
- 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.' },
138
285
  timeoutSeconds: { type: 'number', exclusiveMinimum: 0, maximum: 600, description: 'How long to wait at most, seconds — 120 by default.' },
139
286
  },
140
287
  additionalProperties: false,
141
288
  },
142
289
  annotations: { readOnlyHint: true, openWorldHint: false },
143
- call: async (args) => {
290
+ call: async (args, ctx) => {
291
+ if (ctx) ctx.fields.wait = args?.for ?? (args?.job !== undefined ? 'job' : 'page')
144
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
+ }
145
302
  if (!(await link.waitForPage(Math.max(0, until - Date.now())))) return text(status())
146
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
+ }
147
311
  while (Date.now() < until) {
148
312
  const state = await link.run('session.state', {}).catch(() => null)
149
313
  if (state && state.busy === null && state.running === null) break
150
314
  await new Promise((r) => setTimeout(r, 500))
151
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) })
152
321
  }
153
322
  return text(status())
154
323
  },
@@ -156,11 +325,17 @@ export function createMcpServer({
156
325
  ]
157
326
 
158
327
  let listed = ''
159
- 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 }))]
160
334
 
161
335
  server.setRequestHandler(ListToolsRequestSchema, async () => {
162
336
  const tools = listNow()
163
337
  listed = JSON.stringify(tools)
338
+ trace?.write({ ev: 'list', t: trace.now(), tools: tools.length, bytes: Buffer.byteLength(listed, 'utf8') })
164
339
  return { tools }
165
340
  })
166
341
 
@@ -178,15 +353,242 @@ export function createMcpServer({
178
353
  })
179
354
  }
180
355
 
181
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
182
- const { name, arguments: args } = request.params
183
- const mine = own.find((t) => t.name === name)
184
- if (mine) return mine.call(args)
356
+ /** One of the page's tools run on the page: the result as MCP content,
357
+ * or a failure. */
358
+ const runPageTool = async (name, args, onProgress, { timings = false, ms = false } = {}) => {
185
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.` })
186
362
  if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
187
363
  if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
188
364
  return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
189
365
  }
366
+ try {
367
+ const { input, binaryKey } = await inputFor(command, args, { cwd })
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 }
371
+ const image = command.returnsImage ? await imageContent(result, args, { cwd, outDir }) : null
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
+ }
379
+ return text(await saveFileResult(result, args, { cwd, outDir }), Boolean(command.exact))
380
+ } catch (e) {
381
+ return failure(e)
382
+ }
383
+ }
384
+
385
+ // Several of the page's tools in one call — what cuts a run of twenty
386
+ // scan_nearest calls to one round trip. Each answer is the tool's own; a
387
+ // picture comes back as the text beside it (where it was written, if a
388
+ // path was given), not as an image.
389
+ own.push({
390
+ name: 'scanruler_batch',
391
+ title: 'Run several ScanRuler tools',
392
+ description:
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.',
394
+ inputSchema: {
395
+ type: 'object',
396
+ properties: {
397
+ calls: {
398
+ type: 'array',
399
+ minItems: 1,
400
+ maxItems: 200,
401
+ description: 'The calls, in order.',
402
+ items: {
403
+ type: 'object',
404
+ properties: {
405
+ tool: { type: 'string', minLength: 1, description: 'A tool’s name, as listed — scan_nearest, element_fit.' },
406
+ arguments: { type: 'object', description: 'Its arguments, as the tool takes them; {} for none.' },
407
+ },
408
+ required: ['tool'],
409
+ additionalProperties: false,
410
+ },
411
+ },
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,
416
+ },
417
+ required: ['calls'],
418
+ additionalProperties: false,
419
+ },
420
+ annotations: { readOnlyHint: false, openWorldHint: false },
421
+ call: async (args, ctx) => {
422
+ const calls = Array.isArray(args?.calls) ? args.calls : []
423
+ if (calls.length === 0) return failure({ code: 'invalid_input', message: 'calls: give at least one call.' })
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
427
+ },
428
+ })
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
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) => {
589
+ const { name, arguments: args } = request.params
590
+ const mine = own.find((t) => t.name === name)
591
+ if (mine) return mine.call(args, ctx)
190
592
  // Progress lines from the page, for a client that asked for them.
191
593
  const token = request.params._meta?.progressToken
192
594
  let step = 0
@@ -196,16 +598,18 @@ export function createMcpServer({
196
598
  : (line) => {
197
599
  extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
198
600
  }
199
- try {
200
- const { input, binaryKey } = await inputFor(command, args, { cwd })
201
- const result = await link.run(command.name, input, binaryKey, onProgress)
202
- const image = command.returnsImage ? await imageContent(result, args, { cwd, outDir }) : null
203
- if (image) return { content: image }
204
- return text(await saveFileResult(result, args, { cwd, outDir }))
205
- } catch (e) {
206
- return failure(e)
207
- }
208
- })
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 }))
209
613
 
210
614
  return server
211
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/serve.js CHANGED
@@ -31,7 +31,7 @@ const TYPES = {
31
31
  }
32
32
 
33
33
  /** The file a URL path names under `root`, or null for one outside it. */
34
- export function fileFor(root, urlPath) {
34
+ function fileFor(root, urlPath) {
35
35
  const path = decodeURIComponent(urlPath.split('?')[0].split('#')[0])
36
36
  const file = resolve(root, normalize('.' + (path.endsWith('/') ? `${path}index.html` : path)))
37
37
  return file === root || file.startsWith(root + sep) ? file : null