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/src/mcp.js CHANGED
@@ -1,290 +1,1155 @@
1
- // SPDX-License-Identifier: AGPL-3.0-only
2
- // The MCP side: the tools an agent lists and calls. Each of the page's
3
- // commands is a tool (tools.js); four more are this server's own —
4
- // scanruler_status, to see whether a ScanRuler page is connected and how to
5
- // connect one; scanruler_open, to open one in the user's browser, paired;
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.
8
-
9
- import { readFileSync } from 'node:fs'
10
- import { Server } from '@modelcontextprotocol/sdk/server/index.js'
11
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
12
- import { agentJson, imageContent, inputFor, saveFileResult, toolFor, toolName } from './tools.js'
13
-
14
- /** The app's own commands as of this release, so the tools are there before
15
- * a page connects — unless a page has connected before, whose list was
16
- * kept (`remembered`); a connected page's own list replaces either, its
17
- * plugins' commands included. Kept in step by the app's tests
18
- * (tests/mcpCommands.test.ts). */
19
- export const SNAPSHOT = JSON.parse(readFileSync(new URL('./commands.json', import.meta.url), 'utf8'))
20
-
21
- const INSTRUCTIONS = [
22
- '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
- '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.',
24
- '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
- '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
- '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(' ')
30
-
31
- const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
32
- const failure = (e) => {
33
- const code = e?.code ?? 'failed'
34
- const message = e?.message ?? String(e)
35
- return { ...text(`${code}: ${message}`), isError: true }
36
- }
37
-
38
- /**
39
- * @param {{
40
- * link: import('./relay.js').SharedLink | import('./link.js').PageLink,
41
- * version: string,
42
- * cwd: string,
43
- * outDir: string,
44
- * connectHelp: () => string,
45
- * serverInfo: () => Record<string, unknown>,
46
- * pageTimeoutMs?: number,
47
- * remembered?: { load(): unknown[] | null, save(commands: unknown[]): void } | null,
48
- * openPage?: () => Promise<boolean>,
49
- * openGraceMs?: number,
50
- * openWaitMs?: number,
51
- * }} options — `openPage` opens the pairing link in the user's browser
52
- * (browser.js), true when it could; scanruler_open first gives a tab that
53
- * is already paired `openGraceMs` to connect by itself — the page tries
54
- * again at most ten seconds apart — and then waits `openWaitMs` for the
55
- * one it opened, the user's answer to the browser's question included.
56
- * Both together stay under the minute some clients give a tool call.
57
- */
58
- export function createMcpServer({
59
- link,
60
- version,
61
- cwd,
62
- outDir,
63
- connectHelp,
64
- serverInfo,
65
- pageTimeoutMs = 15_000,
66
- remembered = null,
67
- openPage = async () => false,
68
- openGraceMs = 11_000,
69
- openWaitMs = 40_000,
70
- }) {
71
- const server = new Server(
72
- { name: 'scanruler', title: 'ScanRuler', version },
73
- { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS },
74
- )
75
-
76
- // The last connected page's commands, for listing before a page is back:
77
- // a client that lists the tools once — at its start, before the tab has
78
- // reconnected — and does not list them again when told they changed
79
- // still sees every tool the tab offers.
80
- let lastPage = remembered?.load() ?? null
81
- const commands = () => (link.page?.commands?.length ? link.page.commands : lastPage ?? SNAPSHOT)
82
- const remember = () => {
83
- const list = link.page?.commands
84
- if (!list?.length) return
85
- lastPage = list
86
- try {
87
- remembered?.save(list)
88
- } catch {
89
- // Not kept: the next run lists the app's own until the page is back.
90
- }
91
- }
92
-
93
- const status = () => ({
94
- connected: link.connected,
95
- page: link.page
96
- ? {
97
- app: link.page.app,
98
- version: link.page.version,
99
- origin: link.page.origin,
100
- plugins: link.page.plugins ?? [],
101
- viewport: link.page.viewport ?? null,
102
- commands: link.page.commands.length,
103
- }
104
- : null,
105
- server: serverInfo(),
106
- ...(link.connected ? {} : { howToConnect: connectHelp() }),
107
- })
108
-
109
- const own = [
110
- {
111
- name: 'scanruler_status',
112
- 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.',
114
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
115
- annotations: { readOnlyHint: true, openWorldHint: false },
116
- call: async () => text(status()),
117
- },
118
- {
119
- name: 'scanruler_open',
120
- title: 'Open ScanRuler',
121
- description: 'Open ScanRuler in the user’s default browser, paired with this server, and wait for the page to connect — for when scanruler_status says none is. The first time, the browser asks the user whether the site may reach apps on this computer; they must allow it. Returns the status, with opened: whether the browser was asked to open it. If connected is still false, tell the user what howToConnect says.',
122
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
123
- annotations: { readOnlyHint: false, openWorldHint: false },
124
- call: async () => {
125
- // A tab that was paired before is likely open and on its way back.
126
- if (link.connected || (await link.waitForPage(openGraceMs))) return text({ ...status(), opened: false })
127
- const opened = await openPage().catch(() => false)
128
- if (opened) await link.waitForPage(openWaitMs)
129
- return text({ ...status(), opened })
130
- },
131
- },
132
- {
133
- name: 'scanruler_wait',
134
- 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.',
136
- inputSchema: {
137
- type: 'object',
138
- properties: {
139
- for: { type: 'string', enum: ['page', 'idle'], description: 'page (the default) or idle.' },
140
- timeoutSeconds: { type: 'number', exclusiveMinimum: 0, maximum: 600, description: 'How long to wait at most, seconds — 120 by default.' },
141
- },
142
- additionalProperties: false,
143
- },
144
- annotations: { readOnlyHint: true, openWorldHint: false },
145
- call: async (args) => {
146
- const until = Date.now() + 1000 * (args?.timeoutSeconds ?? 120)
147
- if (!(await link.waitForPage(Math.max(0, until - Date.now())))) return text(status())
148
- if (args?.for === 'idle') {
149
- while (Date.now() < until) {
150
- const state = await link.run('session.state', {}).catch(() => null)
151
- if (state && state.busy === null && state.running === null) break
152
- await new Promise((r) => setTimeout(r, 500))
153
- }
154
- }
155
- return text(status())
156
- },
157
- },
158
- ]
159
-
160
- let listed = ''
161
- const listNow = () => [...own.map(({ call: _call, ...tool }) => tool), ...commands().map((c) => toolFor(c, { outDir }))]
162
-
163
- server.setRequestHandler(ListToolsRequestSchema, async () => {
164
- const tools = listNow()
165
- listed = JSON.stringify(tools)
166
- return { tools }
167
- })
168
-
169
- // A page with other commands than were listed — plugins, another version,
170
- // a command whose input or description changed — has the client list them
171
- // again.
172
- const relist = () => {
173
- if (!listed) return
174
- if (JSON.stringify(listNow()) !== listed) server.sendToolListChanged().catch(() => {})
175
- }
176
- for (const event of ['connected', 'commands']) {
177
- link.on(event, () => {
178
- remember()
179
- relist()
180
- })
181
- }
182
-
183
- /** One of the page's tools run on the page: the result as MCP content,
184
- * or a failure. */
185
- const runPageTool = async (name, args, onProgress) => {
186
- const command = commands().find((c) => toolName(c) === name)
187
- if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
188
- if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
189
- return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
190
- }
191
- try {
192
- const { input, binaryKey } = await inputFor(command, args, { cwd })
193
- const result = await link.run(command.name, input, binaryKey, onProgress)
194
- const image = command.returnsImage ? await imageContent(result, args, { cwd, outDir }) : null
195
- if (image) return { content: image }
196
- return text(await saveFileResult(result, args, { cwd, outDir }), Boolean(command.exact))
197
- } catch (e) {
198
- return failure(e)
199
- }
200
- }
201
-
202
- // Several of the page's tools in one call — what cuts a run of twenty
203
- // scan_nearest calls to one round trip. Each answer is the tool's own; a
204
- // picture comes back as the text beside it (where it was written, if a
205
- // path was given), not as an image.
206
- own.push({
207
- name: 'scanruler_batch',
208
- title: 'Run several ScanRuler tools',
209
- 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.',
211
- inputSchema: {
212
- type: 'object',
213
- properties: {
214
- calls: {
215
- type: 'array',
216
- minItems: 1,
217
- maxItems: 200,
218
- description: 'The calls, in order.',
219
- items: {
220
- type: 'object',
221
- properties: {
222
- tool: { type: 'string', minLength: 1, description: 'A tool’s name, as listed — scan_nearest, element_fit.' },
223
- arguments: { type: 'object', description: 'Its arguments, as the tool takes them; {} for none.' },
224
- },
225
- required: ['tool'],
226
- additionalProperties: false,
227
- },
228
- },
229
- stopOnError: { type: 'boolean', description: 'Stop at the first refused call (the default), or run every call and report each.' },
230
- },
231
- required: ['calls'],
232
- additionalProperties: false,
233
- },
234
- annotations: { readOnlyHint: false, openWorldHint: false },
235
- call: async (args) => {
236
- const calls = Array.isArray(args?.calls) ? args.calls : []
237
- 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)
270
- },
271
- })
272
-
273
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
274
- const { name, arguments: args } = request.params
275
- const mine = own.find((t) => t.name === name)
276
- if (mine) return mine.call(args)
277
- // Progress lines from the page, for a client that asked for them.
278
- const token = request.params._meta?.progressToken
279
- let step = 0
280
- const onProgress =
281
- token === undefined
282
- ? undefined
283
- : (line) => {
284
- extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
285
- }
286
- return runPageTool(name, args, onProgress)
287
- })
288
-
289
- return server
290
- }
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // The MCP side: the tools an agent lists and calls. Each of the page's
3
+ // commands is a tool (tools.js); six more are this server's own —
4
+ // scanruler_status, to see whether a ScanRuler page is connected and how to
5
+ // connect one; scanruler_open, to open one in the user's browser, paired;
6
+ // scanruler_wait, to wait for it or for it to be idle; scanruler_batch, to
7
+ // run several of the page's tools in one call; scanruler_write, to write a
8
+ // text file — a report — next to the scan; and scanruler_files, to list
9
+ // what is there. In compact mode, for a model with a small context window,
10
+ // the page's tools are not listed but for those that return a picture,
11
+ // which a batch cannot show — it writes the picture to a file and gives its
12
+ // text: one more of the server's own, scanruler_tools, lists them a line
13
+ // each and gives the definitions of those asked for, and scanruler_batch
14
+ // runs them. Headless (engine.js), the tools are the commands ScanRuler
15
+ // runs in this process with no page, listed in either mode.
16
+ //
17
+ // No call is held past the answer limit (`answerWithinMs`): a client, or a
18
+ // bridge between it and this computer, may cut a call at a minute. A change
19
+ // or a batch still running then answers with its job, and runs on.
20
+
21
+ import { randomBytes } from 'node:crypto'
22
+ import { readFileSync } from 'node:fs'
23
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
24
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
25
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
26
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
27
+ import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, replacesSession, saveFileResult, toolFor, toolName, waitIn, withFiles } from './tools.js'
28
+ import { jsonBytes, replyMeasure } from './trace.js'
29
+
30
+ /** The app's own commands as of this release, so the tools are there before
31
+ * a page connects — unless a page has connected before whose list was kept
32
+ * (`remembered`) and is no older; a connected page's own list replaces
33
+ * either, its plugins' commands included. Kept in step by the app's tests
34
+ * (tests/mcpCommands.test.ts), with the app's version and when it was
35
+ * written (SNAPSHOT_OF). */
36
+ const SNAPSHOT_FILE = JSON.parse(readFileSync(new URL('./commands.json', import.meta.url), 'utf8'))
37
+ export const SNAPSHOT = Array.isArray(SNAPSHOT_FILE) ? SNAPSHOT_FILE : SNAPSHOT_FILE.commands
38
+ export const SNAPSHOT_OF = { version: SNAPSHOT_FILE.app ?? null, saved: SNAPSHOT_FILE.saved ?? null }
39
+
40
+ /** -1, 0 or 1 as version `a` is older than, the same as or newer than `b`,
41
+ * by their numbers in order — 0.6.10 after 0.6.9. */
42
+ export function compareVersions(a, b) {
43
+ const pa = String(a).split(/[^0-9]+/).filter(Boolean).map(Number)
44
+ const pb = String(b).split(/[^0-9]+/).filter(Boolean).map(Number)
45
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
46
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0)
47
+ if (d) return Math.sign(d)
48
+ }
49
+ return 0
50
+ }
51
+
52
+ /** The list to give before a page connects, from the one kept from the
53
+ * last page (`{ commands, version, plugins, saved }`, or a bare list) and
54
+ * this server's own: the kept one when it is no older — a page's own,
55
+ * plugins included — else this server's, with the kept page's plugins'
56
+ * commands, which it lacks. A kept list with no version (a server before
57
+ * 0.4.0 kept it) is the older; the same version goes by when each was
58
+ * written. `from` says which, for the status. */
59
+ export function listBeforePage(kept, snapshot = SNAPSHOT, of = SNAPSHOT_OF) {
60
+ if (!kept) return null
61
+ const record = Array.isArray(kept) ? { commands: kept } : kept
62
+ if (!record.commands?.length) return null
63
+ const order = record.version && of.version ? compareVersions(record.version, of.version) : record.version ? 1 : -1
64
+ const keptNewer = order > 0 || (order === 0 && !(record.saved && of.saved && record.saved < of.saved))
65
+ const page = `ScanRuler ${record.version ?? 'of no version'}`
66
+ if (keptNewer) return { commands: record.commands, from: `the list kept from the last page that connected (${page})` }
67
+ // A plugin's commands are under its id (the app's registry); with no
68
+ // plugins kept, those under a name the app's own list has none of.
69
+ const namespace = (c) => String(c.name).split('.')[0]
70
+ const own = new Set(snapshot.map(namespace))
71
+ const plugin = record.plugins ? (c) => record.plugins.includes(namespace(c)) : (c) => !own.has(namespace(c))
72
+ const added = record.commands.filter(plugin)
73
+ const commands = [...snapshot, ...added].sort((a, b) => a.name.localeCompare(b.name))
74
+ return { commands, from: `this server’s own list (ScanRuler ${of.version ?? 'of no version'}), newer than the one kept from the last page (${page})${added.length ? `, and that page’s plugins’ commands (${added.length})` : ''}` }
75
+ }
76
+
77
+ /** How the page's commands are listed: `full`, each its own tool, or
78
+ * `compact`, read through scanruler_tools. */
79
+ export const TOOL_MODES = ['full', 'compact']
80
+
81
+ /** The most definitions scanruler_tools gives in one answer: twenty are
82
+ * some ten thousand tokens, a small window's fair share. */
83
+ const MAX_DEFINITIONS = 20
84
+
85
+ const ABOUT = [
86
+ '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.',
87
+ '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.',
88
+ 'Before a task’s first step, read its guide with guide_read — how to measure a scan, model a part or turn a scan into CAD, written for the ScanRuler build connected: guide_read with no topic lists the guides and their sections.',
89
+ ]
90
+ const WORKING = [
91
+ '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.',
92
+ '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.',
93
+ '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").',
94
+ 'A change, a batch or a long read still running at the server’s answer limit (scanruler_status: server.answerWithin) answers { queued: true, job } and runs on: scanruler_wait { for: "job", job } gives its answer — call it again while it says running.',
95
+ ]
96
+ const INSTRUCTIONS = {
97
+ full: [
98
+ ...ABOUT,
99
+ ...WORKING,
100
+ 'view_render shows you the 3D view as a picture; view_set turns it to a standard view or fits it.',
101
+ '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.',
102
+ ].join(' '),
103
+ compact: [
104
+ ...ABOUT,
105
+ '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.',
106
+ '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.',
107
+ ...WORKING,
108
+ '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 is not shown: it is written to a file — the call’s path, or a new file in the server’s output folder — and its result says where (file.path). view_set turns the view.',
109
+ ].join(' '),
110
+ }
111
+
112
+ /** A command in a line, for scanruler_tools' list: its title, or the first
113
+ * sentence of its description. */
114
+ function summary(command) {
115
+ if (command.title) return command.title
116
+ const description = String(command.description ?? '').replace(/\s+/g, ' ').trim()
117
+ return /^.*?[.!?](?= |$)/.exec(description)?.[0] ?? description
118
+ }
119
+
120
+ /** What the server says it is when it runs ScanRuler itself, headless. */
121
+ const HEADLESS_ABOUT = [
122
+ '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").',
123
+ 'Before a task’s first step, read its guide with guide_read — written for this build: guide_read with no topic lists the guides and their sections.',
124
+ ]
125
+ const HEADLESS_WORKING = [
126
+ '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.',
127
+ 'A change or a batch still running at the server’s answer limit (scanruler_status: server.answerWithin) answers { queued: true, job } and runs on: scanruler_wait { for: "job", job } gives its answer.',
128
+ ]
129
+ const HEADLESS_INSTRUCTIONS = {
130
+ full: [
131
+ ...HEADLESS_ABOUT,
132
+ '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.',
133
+ ...HEADLESS_WORKING,
134
+ 'scanruler_batch runs several tools in one call, in order, and stops at the first that is refused unless told to go on.',
135
+ ].join(' '),
136
+ compact: [
137
+ ...HEADLESS_ABOUT,
138
+ '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.',
139
+ '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.',
140
+ ...HEADLESS_WORKING,
141
+ ].join(' '),
142
+ }
143
+
144
+ /** How many jobs the server keeps answers of, the oldest finished let go
145
+ * first. */
146
+ const JOBS_KEPT = 50
147
+
148
+ /** How long the status waits for the page to say what its session holds,
149
+ * ms; past it the status goes without. */
150
+ const SESSION_WITHIN_MS = 3_000
151
+
152
+ /** The kinds of file scanruler_write writes: text, for a report. */
153
+ const TEXT_KINDS = ['.md', '.txt', '.csv', '.json']
154
+
155
+ /** A change answered directly that took longer than this has likely
156
+ * outlived its client's wait (Claude Desktop's is a minute): its answer
157
+ * counts as not yet seen, for scanruler_wait { for: "idle" } to give. */
158
+ const SEEN_WITHIN_MS = 55_000
159
+
160
+ /** How long a call is held before it answers with its job, by default: under
161
+ * the minute a client or a bridge may cut it at. */
162
+ export const ANSWER_WITHIN_MS = 45_000
163
+
164
+ /** How long a batch waits for the page to be idle — before it retries a
165
+ * call refused busy, and before it rolls back. */
166
+ const IDLE_WAIT_MS = 60_000
167
+
168
+ /** How long a listing of the tools waits for a page that connected before
169
+ * and is not back yet, ms. */
170
+ const LIST_WAIT_MS = 3_000
171
+
172
+ /** The most characters an answer carries — some ten thousand tokens. A
173
+ * longer one is written to a file in the output folder, and the answer
174
+ * says where, how large and what it holds. */
175
+ export const ANSWER_CAP = 40_000
176
+
177
+ /** How many jobs the status lists besides those running, the newest. */
178
+ const JOBS_LISTED = 5
179
+
180
+ /** How many folders are kept, the latest first. */
181
+ const FOLDERS_KEPT = 10
182
+
183
+ /** How many entries scanruler_files lists at most. */
184
+ const FILES_LISTED = 300
185
+
186
+ const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)))
187
+
188
+ const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
189
+ const failure = (e) => {
190
+ const code = e?.code ?? 'failed'
191
+ const message = e?.message ?? String(e)
192
+ return { ...text(`${code}: ${message}`), isError: true }
193
+ }
194
+
195
+ /**
196
+ * @param {{
197
+ * link: import('./relay.js').SharedLink | import('./link.js').PageLink,
198
+ * version: string,
199
+ * cwd: string,
200
+ * outDir: string,
201
+ * connectHelp: () => string,
202
+ * serverInfo: () => Record<string, unknown>,
203
+ * pageTimeoutMs?: number,
204
+ * remembered?: { load(): unknown[] | null, save(commands: unknown[]): void } | null,
205
+ * openPage?: () => Promise<boolean>,
206
+ * openGraceMs?: number,
207
+ * openWaitMs?: number,
208
+ * tools?: 'full' | 'compact',
209
+ * trace?: ReturnType<typeof import('./trace.js').createTrace>,
210
+ * answerWithinMs?: number,
211
+ * folderStore?: { load(): string[] | null, save(folders: string[]): void } | null,
212
+ * listWaitMs?: number,
213
+ * answerCap?: number,
214
+ * }} options — `tools` is how the page's commands are listed (TOOL_MODES).
215
+ * `listWaitMs`: how long a listing waits for a page that connected before
216
+ * and is not back yet. `answerCap`: the most characters an answer
217
+ * carries; a longer one goes to a file in `outDir` (ANSWER_CAP).
218
+ * `trace`, when given, gets a line per call (trace.js).
219
+ * `answerWithinMs` is how long a job's call is held before it answers
220
+ * with the job, and the most scanruler_wait waits; 0 holds it to the end.
221
+ * `folderStore` keeps the folders scans came from between runs
222
+ * (config.js).
223
+ * `openPage` opens the pairing link in the user's browser
224
+ * (browser.js), true when it could; scanruler_open first gives a tab that
225
+ * is already paired `openGraceMs` to connect by itself — the page tries
226
+ * again at most ten seconds apart — and then waits `openWaitMs` for the
227
+ * one it opened, the user's answer to the browser's question included.
228
+ * Both together stay under the minute some clients give a tool call.
229
+ */
230
+ export function createMcpServer({
231
+ link,
232
+ version,
233
+ cwd,
234
+ outDir,
235
+ connectHelp,
236
+ serverInfo,
237
+ pageTimeoutMs = 15_000,
238
+ remembered = null,
239
+ openPage = async () => false,
240
+ openGraceMs = 11_000,
241
+ openWaitMs = 40_000,
242
+ tools: toolMode = 'full',
243
+ trace = null,
244
+ answerWithinMs = ANSWER_WITHIN_MS,
245
+ folderStore = null,
246
+ listWaitMs = LIST_WAIT_MS,
247
+ answerCap = ANSWER_CAP,
248
+ }) {
249
+ // The most any call is held, ms — Infinity with no limit.
250
+ const holdMs = answerWithinMs > 0 ? answerWithinMs : Infinity
251
+ const holdS = answerWithinMs > 0 ? answerWithinMs / 1000 : null
252
+ const headless = link.backend === 'headless'
253
+ const compact = toolMode === 'compact'
254
+ const server = new Server(
255
+ { name: 'scanruler', title: 'ScanRuler', version },
256
+ { capabilities: { tools: { listChanged: true } }, instructions: (headless ? HEADLESS_INSTRUCTIONS : INSTRUCTIONS)[compact ? 'compact' : 'full'] },
257
+ )
258
+
259
+ // The last connected page's commands, for listing before a page is back:
260
+ // a client that lists the tools once — at its start, before the tab has
261
+ // reconnected — and does not list them again when told they changed
262
+ // still sees every tool the tab offers.
263
+ // A server shipped with a newer list than the one kept lists its own: the
264
+ // kept one may be days old (listBeforePage).
265
+ let lastPage = listBeforePage(remembered?.load() ?? null)
266
+ const commands = () => (link.page?.commands?.length ? link.page.commands : lastPage?.commands ?? SNAPSHOT)
267
+ const remember = () => {
268
+ const list = link.page?.commands
269
+ if (!list?.length) return
270
+ lastPage = { commands: list, from: 'the list kept from the last page that connected' }
271
+ try {
272
+ remembered?.save({ commands: list, version: link.page.version ?? null, plugins: link.page.plugins ?? null })
273
+ } catch {
274
+ // Not kept: the next run lists the app's own until the page is back.
275
+ }
276
+ }
277
+
278
+ // ---- jobs ---------------------------------------------------------------
279
+ // Every call that changes the session is a job, its answer kept: one
280
+ // started with wait: false answers at once with the job, and one the
281
+ // client gave up waiting for keeps running and keeps its reply — the
282
+ // status lists both, and scanruler_wait { for: "job" } gives the answer.
283
+ // The page keeps what each run under a job did (session.recent), so a
284
+ // copy of this server started later can still say: a job's name carries
285
+ // this copy's own mark, and is not taken again by the next.
286
+ const jobs = new Map()
287
+ let nextJob = 1
288
+ const copyMark = randomBytes(3).toString('hex').slice(0, 4)
289
+ // `ctx` is the call's own, for the trace: the job is named on the call's
290
+ // line, and one answered before it was done (queued) gets a line when it
291
+ // is. `run` is handed the job's name, for the page.
292
+ const startJob = (tool, run, ctx = null, queued = false) => {
293
+ const job = { job: `j${nextJob++}-${copyMark}`, tool, state: 'running', started: Date.now(), ms: null, reply: null, queued }
294
+ if (ctx) Object.assign(ctx.fields, { job: job.job, ...(queued ? { queued: true } : {}) })
295
+ const finished = (reply) => {
296
+ job.state = reply.isError ? 'failed' : 'done'
297
+ job.ms = Date.now() - job.started
298
+ job.reply = reply
299
+ // Answered to the client: seen, unless queued or likely timed out.
300
+ job.told = !job.queued && job.ms < SEEN_WITHIN_MS
301
+ if (job.queued && trace) trace.write({ ev: 'job', job: job.job, call: ctx?.call ?? null, tool, t: job.started, ms: job.ms, ...replyMeasure(reply) })
302
+ return reply
303
+ }
304
+ job.done = Promise.resolve()
305
+ .then(() => run(job.job))
306
+ .then(finished, (e) => finished(failure(e)))
307
+ jobs.set(job.job, job)
308
+ // The newest kept, but never one still running.
309
+ for (const [id, j] of jobs) {
310
+ if (jobs.size <= JOBS_KEPT) break
311
+ if (j.state !== 'running') jobs.delete(id)
312
+ }
313
+ return job
314
+ }
315
+ const jobBrief = (j) => ({ job: j.job, tool: j.tool, state: j.state, ...(j.ms !== null ? { ms: j.ms } : { runningMs: Date.now() - j.started }) })
316
+ /** A job's answer as scanruler_wait gives it: the tool's own answer
317
+ * under result, or its refusal under error. */
318
+ const jobAnswer = (j) => {
319
+ if (!j.reply) return jobBrief(j)
320
+ const body = j.reply.content.find((x) => x.type === 'text')?.text ?? ''
321
+ if (j.reply.isError) {
322
+ const m = /^([a-z_]+): ([\s\S]*)$/.exec(body)
323
+ return { ...jobBrief(j), error: m ? { code: m[1], message: m[2] } : { code: 'failed', message: body } }
324
+ }
325
+ let result
326
+ try {
327
+ result = JSON.parse(body)
328
+ } catch {
329
+ result = body
330
+ }
331
+ return { ...jobBrief(j), result }
332
+ }
333
+
334
+ /** A job answered at once with its name: wait: false. */
335
+ const queuedReply = (job) => text({ queued: true, job: job.job, tool: job.tool })
336
+
337
+ /** A job's call answered: its reply once done, or — still running at
338
+ * the answer limit — its name, as with wait: false. */
339
+ const answerFor = async (job, ctx) => {
340
+ if (holdMs === Infinity) return job.done
341
+ let timer
342
+ const reply = await Promise.race([job.done, new Promise((r) => (timer = setTimeout(() => r(null), holdMs)))])
343
+ clearTimeout(timer)
344
+ if (reply) return reply
345
+ job.queued = true
346
+ if (ctx) ctx.fields.queued = true
347
+ return text({
348
+ queued: true,
349
+ job: job.job,
350
+ tool: job.tool,
351
+ after: holdS,
352
+ note: `Still running after ${holdS} s, and running on. scanruler_wait { for: "job", job: "${job.job}" } gives its answer — call it again while it says running.`,
353
+ })
354
+ }
355
+
356
+ /** Whether the page is done with what it is busy with — polled until
357
+ * `until` (ms since the epoch). A page that does not say is taken as
358
+ * idle. */
359
+ const pageIdle = async (until) => {
360
+ for (;;) {
361
+ const state = await link.run('session.state', {}).catch(() => null)
362
+ if (!state || typeof state !== 'object' || (state.busy == null && state.running == null)) return true
363
+ if (Date.now() >= until) return false
364
+ await sleep(Math.min(500, until - Date.now()))
365
+ }
366
+ }
367
+
368
+ /** What the page says was run under a job this copy does not know — one
369
+ * of a copy before it — waiting while a run under it goes on, until
370
+ * `until`. Null when the page has nothing under it, or cannot say. */
371
+ const jobFromPage = async (job, until) => {
372
+ if (headless || !link.connected) return null
373
+ for (;;) {
374
+ let outcomes
375
+ try {
376
+ outcomes = (await link.run('session.recent', { job, limit: 100 }))?.outcomes
377
+ } catch {
378
+ return null
379
+ }
380
+ if (!Array.isArray(outcomes) || outcomes.length === 0) return null
381
+ const running = outcomes.some((o) => o.state === 'running')
382
+ if (!running || Date.now() >= until) return pageJobAnswer(job, outcomes.slice().reverse(), running)
383
+ await sleep(Math.min(1000, until - Date.now()))
384
+ }
385
+ }
386
+ /** The page's record of a job, in run order, as scanruler_wait gives it:
387
+ * a single run as a job's answer, several — a batch — as calls. */
388
+ const pageJobAnswer = (job, runs, running) => {
389
+ const calls = runs.map((o) => ({
390
+ tool: toolName({ name: String(o.name) }),
391
+ state: o.state,
392
+ ms: o.ms,
393
+ ...(o.result !== undefined ? { result: o.result } : {}),
394
+ ...(o.resultOmitted ? { resultOmitted: o.resultOmitted } : {}),
395
+ ...(o.error ? { error: o.error } : {}),
396
+ }))
397
+ const from = `the page (session.recent): this server copy, started at ${startedAt}, did not start ${job}; the page kept what was run under it.`
398
+ if (calls.length === 1) return { job, ...calls[0], from }
399
+ return {
400
+ job,
401
+ state: running ? 'running' : 'ended',
402
+ calls,
403
+ from,
404
+ note: 'A batch’s own answer — its results in full, what it undid — belonged to the server copy that ran it. These are the page’s commands under the job, in order; a batch stopped part way shows only those it ran. Read session_state for where the session stands.',
405
+ }
406
+ }
407
+
408
+ /** Whether the tab is hidden now — null with no page, or headless. */
409
+ const hiddenNow = () => (link.connected && link.page && !headless ? (link.page.hidden ?? false) : null)
410
+ const runningJobs = () => [...jobs.values()].filter((j) => j.state === 'running').length
411
+
412
+ /** A finished job as scanruler_wait { for: "idle" } lists it: a failed
413
+ * one with its error, a batch with the call it stopped at. */
414
+ const finishedOut = (j) => {
415
+ const answer = jobAnswer(j)
416
+ if (j.state === 'failed') return { ...jobBrief(j), error: answer.error }
417
+ const failed = j.tool === 'scanruler_batch' ? answer.result?.failed : undefined
418
+ return { ...jobBrief(j), ...(failed ? { failed } : {}) }
419
+ }
420
+
421
+ // Since when this copy of the server keeps jobs: one asked of a copy
422
+ // before it — another process, or this one restarted — is not known here.
423
+ const startedAt = new Date().toISOString()
424
+ const noJobs = `None asked of this server copy, which started at ${startedAt}. A change asked of an earlier copy is not known here: session_state says what the page holds.`
425
+
426
+ /** What the page's session holds, in brief, for the status: the scan,
427
+ * the project's name, whether it has unsaved changes, and its work
428
+ * counted — read off session.state, and left out when the page does not
429
+ * answer soon. */
430
+ const sessionBrief = async () => {
431
+ if (!link.connected) return {}
432
+ let timer
433
+ const state = await Promise.race([
434
+ link.run('session.state', {}).catch(() => null),
435
+ new Promise((r) => (timer = setTimeout(() => r(null), SESSION_WITHIN_MS))),
436
+ ])
437
+ clearTimeout(timer)
438
+ if (!state || typeof state !== 'object') return {}
439
+ const project = state.project ?? {}
440
+ return {
441
+ session: {
442
+ scan: state.scan?.fileName ?? null,
443
+ ...(project.name ? { project: project.name } : {}),
444
+ unsaved: project.unsaved ?? null,
445
+ ...(project.holds ?? {}),
446
+ },
447
+ // The page's word that this server is older than its commands assume.
448
+ ...(typeof state.serverTooOld === 'string' ? { serverTooOld: state.serverTooOld } : {}),
449
+ }
450
+ }
451
+
452
+ // ---- answers that fit -----------------------------------------------------
453
+ // An answer past answerCap — a read with every point in it, a batch of
454
+ // many — would crowd out the agent's context: it is written whole to a
455
+ // file in outDir, and the answer says where, how large and what it holds.
456
+ /** `value` as an answer gives it: itself, or — its JSON past the cap —
457
+ * `{ truncated, file, bytes, keys, brief }`, brief its short fields and
458
+ * those named in `keep`. `tool` names the file. A file that cannot be
459
+ * written leaves the answer whole. */
460
+ const fitted = async (value, tool, exact = false, keep = []) => {
461
+ const body = agentJson(value, exact)
462
+ if (body.length <= answerCap) return value
463
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
464
+ let file = join(outDir, `${tool}-${stamp}.json`)
465
+ try {
466
+ for (let n = 2; await stat(file).catch(() => null); n++) file = join(outDir, `${tool}-${stamp}-${n}.json`)
467
+ await mkdir(outDir, { recursive: true })
468
+ await writeFile(file, body, 'utf8')
469
+ } catch {
470
+ return value
471
+ }
472
+ const object = value !== null && typeof value === 'object' && !Array.isArray(value)
473
+ const short = (k, v) => keep.includes(k) || v === null || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 200)
474
+ const brief = object ? Object.fromEntries(Object.entries(value).filter(([k, v]) => short(k, v))) : {}
475
+ return {
476
+ truncated: true,
477
+ file,
478
+ bytes: Buffer.byteLength(body, 'utf8'),
479
+ ...(object ? { keys: Object.keys(value) } : Array.isArray(value) ? { items: value.length } : {}),
480
+ ...(Object.keys(brief).length ? { brief } : {}),
481
+ note: `The answer is ${Math.round(body.length / 1000)} k characters, past the ${Math.round(answerCap / 1000)} k an answer carries, so it was written whole to file as JSON: read what you need from there, or ask for less next time (fields, limit).`,
482
+ }
483
+ }
484
+
485
+ // ---- folders -------------------------------------------------------------
486
+ // The folders this server opened a scan or a project from, or saved one
487
+ // into, the latest first: where scanruler_write may write and what
488
+ // scanruler_files lists. Kept beside the command list (folderStore), so a
489
+ // copy started afresh — or the other copy beside this one — has them too.
490
+ let folders = []
491
+ const knownFolders = () => {
492
+ try {
493
+ const kept = folderStore?.load()
494
+ if (kept) folders = kept
495
+ } catch {
496
+ // Not read: this copy's own list stands.
497
+ }
498
+ return folders
499
+ }
500
+ const rememberFolder = (file) => {
501
+ const folder = dirname(file)
502
+ folders = [folder, ...knownFolders().filter((f) => f !== folder)].slice(0, FOLDERS_KEPT)
503
+ try {
504
+ folderStore?.save(folders)
505
+ } catch {
506
+ // Not kept: this copy still has it.
507
+ }
508
+ }
509
+
510
+ /** The jobs as the status lists them, newest first: every one running
511
+ * and the newest JOBS_LISTED others; jobsMore counts the rest kept. */
512
+ const jobsListed = () => {
513
+ const newest = [...jobs.values()].reverse()
514
+ let others = 0
515
+ const listed = newest.filter((j) => j.state === 'running' || others++ < JOBS_LISTED)
516
+ const more = newest.length - listed.length
517
+ return { jobs: listed.map(jobBrief), ...(more > 0 ? { jobsMore: more } : {}) }
518
+ }
519
+
520
+ const status = () => ({
521
+ backend: headless ? 'headless' : 'page',
522
+ connected: link.connected,
523
+ page: link.page
524
+ ? {
525
+ app: link.page.app,
526
+ version: link.page.version,
527
+ origin: link.page.origin,
528
+ plugins: link.page.plugins ?? [],
529
+ viewport: link.page.viewport ?? null,
530
+ hidden: link.page.hidden ?? null,
531
+ commands: link.page.commands.length,
532
+ }
533
+ : null,
534
+ server: { ...serverInfo(), answerWithin: holdS },
535
+ ...(link.connected || headless ? {} : { howToConnect: connectHelp() }),
536
+ ...(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".' } : {}),
537
+ ...(jobs.size > 0 ? jobsListed() : { jobsNote: noJobs }),
538
+ ...(!headless && knownFolders().length ? { folders: knownFolders() } : {}),
539
+ ...staleTools(),
540
+ })
541
+
542
+ const own = [
543
+ {
544
+ name: 'scanruler_status',
545
+ title: 'ScanRuler connection status',
546
+ 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. session, with a page, says what it holds: the scan, the project’s name, whether it has changes not saved (unsaved), and its work counted — elements, features, notes … — so a session someone else left is seen before it is replaced (scan_open with saveTo keeps it). backend says whether ScanRuler runs in a tab (page) or in this server (headless). jobs lists the calls that change the session still running and the latest five others, 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, also for a job of an earlier copy of this server, which the page remembers; jobsMore counts the older ones kept. folders: where a scan or a project was opened from or saved into, the latest first — what scanruler_write writes into and scanruler_files lists; kept when the server restarts. server.answerWithin: the seconds a call is held at most before it answers with its job. server.version is this server’s, server.latest the newest on npm when known, server.handedOffFrom the older copy that started this one in its place. serverTooOld, when there, says this server is older than the page needs: tell the person once, in its words, and install nothing yourself. tools, when there, says the tools you were given are not the ones the page offers now (stale): which were added, removed or changed — named the first time, counted after — and which page they were listed from.',
547
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
548
+ annotations: { readOnlyHint: true, openWorldHint: false },
549
+ call: async () => text({ ...status(), ...(await sessionBrief()) }),
550
+ },
551
+ {
552
+ name: 'scanruler_open',
553
+ title: 'Open ScanRuler',
554
+ description: 'Open ScanRuler in the user’s default browser, paired with this server, and wait for the page to connect — for when scanruler_status says none is. The first time, the browser asks the user whether the site may reach apps on this computer; they must allow it. Returns the status, with opened: whether the browser was asked to open it. If connected is still false, tell the user what howToConnect says.',
555
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
556
+ annotations: { readOnlyHint: false, openWorldHint: false },
557
+ call: async () => {
558
+ if (headless) return text({ ...status(), opened: false, note: 'This server runs ScanRuler headless, in this process: there is no page to open.' })
559
+ // A tab that was paired before is likely open and on its way back.
560
+ if (link.connected || (await link.waitForPage(openGraceMs))) return text({ ...status(), opened: false })
561
+ const opened = await openPage().catch(() => false)
562
+ // Within the answer limit, with a little to spare for the answer.
563
+ if (opened) await link.waitForPage(Math.max(1_000, Math.min(openWaitMs, holdMs - openGraceMs - 2_000)))
564
+ return text({ ...status(), opened })
565
+ },
566
+ },
567
+ {
568
+ name: 'scanruler_wait',
569
+ title: 'Wait for ScanRuler',
570
+ 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, and state says idle or running), or until a job is done (for: "job" with job — a call started with wait: false, one answered queued, or one whose answer did not come; scanruler_status lists them). Returns the status — for idle only connected, state and finished — or for a job its answer: { job, tool, state, ms, result } or { …, error: { code, message } } — state running if the time ran out first: call it again. A job of an earlier copy of this server is read from the page's record of it (session.recent), from says so.${holdS ? ` It waits ${holdS} s at most, this server's answer limit, whatever timeoutSeconds says.` : ''} connected is false if the time ran out waiting for a page.`,
571
+ inputSchema: {
572
+ type: 'object',
573
+ properties: {
574
+ for: { type: 'string', enum: ['page', 'idle', 'job'], description: 'page (the default), idle, or job.' },
575
+ job: { type: 'string', minLength: 1, description: 'The job to wait for, with for: "job" — as a call with wait: false, a queued answer or scanruler_status gave it.' },
576
+ timeoutSeconds: { type: 'number', exclusiveMinimum: 0, maximum: 600, description: `How long to wait at most, seconds — 120 by default${holdS ? `, and never past ${holdS}` : ''}.` },
577
+ },
578
+ additionalProperties: false,
579
+ },
580
+ annotations: { readOnlyHint: true, openWorldHint: false },
581
+ call: async (args, ctx) => {
582
+ if (ctx) ctx.fields.wait = args?.for ?? (args?.job !== undefined ? 'job' : 'page')
583
+ // Never held past the answer limit: a cut call says nothing.
584
+ const until = Date.now() + Math.min(1000 * (args?.timeoutSeconds ?? 120), holdMs)
585
+ if (args?.for === 'job' || args?.job !== undefined) {
586
+ const id = String(args?.job ?? '')
587
+ const j = jobs.get(id)
588
+ if (!j) {
589
+ const kept = id ? await jobFromPage(id, until) : null
590
+ if (kept) return text(await fitted(kept, 'scanruler_wait'))
591
+ return failure({ code: 'not_found', message: `There is no job "${id}" — scanruler_status lists the latest. This server copy started at ${startedAt}; a job of an earlier one is not known here, nor did the page keep it (session_recent), so session_state says whether its change was made.` })
592
+ }
593
+ let timer
594
+ await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
595
+ clearTimeout(timer)
596
+ if (j.state !== 'running') j.told = true
597
+ return text(await fitted(jobAnswer(j), 'scanruler_wait'))
598
+ }
599
+ if (!(await link.waitForPage(Math.max(0, until - Date.now())))) return text(status())
600
+ if (args?.for === 'idle') {
601
+ // The changes still running first — their answers are what idle
602
+ // waits for — then the page's own work.
603
+ for (const j of [...jobs.values()].filter((x) => x.state === 'running')) {
604
+ let timer
605
+ await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
606
+ clearTimeout(timer)
607
+ }
608
+ const idle = (await pageIdle(until)) && runningJobs() === 0
609
+ // What finished since it was last said: each job once, a failed
610
+ // one with its error.
611
+ const finished = [...jobs.values()].filter((j) => j.state !== 'running' && !j.told)
612
+ for (const j of finished) j.told = true
613
+ // The state and what finished, not the whole status: an agent
614
+ // waits for idle many times in a run.
615
+ return text({ connected: link.connected, state: idle ? 'idle' : 'running', finished: finished.map(finishedOut), ...(hiddenNow() ? { hidden: true } : {}), ...staleTools() })
616
+ }
617
+ return text(status())
618
+ },
619
+ },
620
+ ]
621
+
622
+ // A report beside the scan, written by this server: no grant of the
623
+ // browser's to save a file, and nowhere but the scan's folder.
624
+ if (!headless) {
625
+ own.push({
626
+ name: 'scanruler_write',
627
+ title: 'Write a text file beside the scan',
628
+ description: `Write a text file — a report, notes, a table of readings — next to the scan, as UTF-8: ${TEXT_KINDS.join(', ')} only, and only inside the folder this server last opened a scan or a project from (scan_open, project_load) or saved one into (saveTo), or a folder under it. A relative path is taken in that folder; an absolute one must lie in one of those folders, and a path may not climb out with "..". A file already there is replaced. Returns its path and size in bytes. Refused while this server has opened no scan or project from a folder — then open the scan with scan_open first, or write the file yourself.`,
629
+ inputSchema: {
630
+ type: 'object',
631
+ properties: {
632
+ path: { type: 'string', minLength: 1, description: `The file: a name in the scan’s folder ("report.md"), a path under it ("report/sigma.md"), or an absolute path in it — ${TEXT_KINDS.join(', ')}.` },
633
+ text: { type: 'string', description: 'What the file holds, as text.' },
634
+ },
635
+ required: ['path', 'text'],
636
+ additionalProperties: false,
637
+ },
638
+ annotations: { readOnlyHint: false, openWorldHint: false },
639
+ call: async (args) => {
640
+ try {
641
+ return text(await writeText(args?.path, args?.text))
642
+ } catch (e) {
643
+ return failure(e)
644
+ }
645
+ },
646
+ })
647
+ own.push({
648
+ name: 'scanruler_files',
649
+ title: 'List the files beside the scan',
650
+ description: `The files in the folder this server last opened a scan or a project from, or saved one into — kept when the server restarts — or in a folder under it (path): each file's name, size in bytes and kind (mesh, project, step, picture, drawing, text or other), newest first, and the folders in it. Where scanruler_write's reports and the files saved beside the scan are; up to ${FILES_LISTED} entries. Refused while no scan or project has been opened from a folder.`,
651
+ inputSchema: {
652
+ type: 'object',
653
+ properties: {
654
+ path: { type: 'string', minLength: 1, description: 'A folder under the scan’s folder, relative to it or absolute; or one of the folders scanruler_status lists. Leave out for the scan’s folder.' },
655
+ },
656
+ additionalProperties: false,
657
+ },
658
+ annotations: { readOnlyHint: true, openWorldHint: false },
659
+ call: async (args) => {
660
+ try {
661
+ return text(await listFiles(args?.path))
662
+ } catch (e) {
663
+ return failure(e)
664
+ }
665
+ },
666
+ })
667
+ }
668
+
669
+ /** scanruler_files' list — see its description. */
670
+ const listFiles = async (path) => {
671
+ const folders = knownFolders()
672
+ if (folders.length === 0) {
673
+ throw { code: 'invalid_state', message: 'This server has opened no scan or project from a folder, so it has none to list. Open the scan with scan_open (or a project with project_load) by its path first.' }
674
+ }
675
+ if (typeof path === 'string' && path.split(/[\\/]/).includes('..')) throw { code: 'invalid_input', message: 'path: ".." is not taken — give a folder in the scan’s folder or under it.' }
676
+ const folder = typeof path === 'string' && path ? (isAbsolute(path) ? resolve(path) : resolve(folders[0], path)) : folders[0]
677
+ const within = (f) => {
678
+ const r = relative(f, folder)
679
+ return r === '' || (!r.startsWith('..') && !isAbsolute(r))
680
+ }
681
+ if (!folders.some(within)) throw { code: 'invalid_input', message: `path: ${folder} is outside the folders a scan or a project was opened from — ${folders.join(', ')}.` }
682
+ let entries
683
+ try {
684
+ entries = await readdir(folder, { withFileTypes: true })
685
+ } catch (e) {
686
+ throw { code: 'not_found', message: `${folder} could not be read: ${e?.code === 'ENOENT' ? 'there is no such folder' : (e?.message ?? String(e))}.` }
687
+ }
688
+ const files = []
689
+ const subfolders = []
690
+ for (const entry of entries) {
691
+ if (entry.isDirectory()) subfolders.push(entry.name)
692
+ else if (entry.isFile()) {
693
+ const info = await stat(join(folder, entry.name)).catch(() => null)
694
+ if (info) files.push({ name: entry.name, size: info.size, kind: fileKind(entry.name), modified: info.mtime.toISOString() })
695
+ }
696
+ }
697
+ files.sort((a, b) => b.modified.localeCompare(a.modified))
698
+ const listed = files.slice(0, FILES_LISTED)
699
+ return {
700
+ folder,
701
+ files: listed,
702
+ folders: subfolders.slice(0, FILES_LISTED),
703
+ ...(files.length > listed.length ? { more: files.length - listed.length } : {}),
704
+ }
705
+ }
706
+
707
+ /** scanruler_write's file, kept to the folders a scan or a project came
708
+ * from — see its description. */
709
+ const writeText = async (path, body) => {
710
+ if (typeof path !== 'string' || path.length === 0) throw { code: 'invalid_input', message: 'path: give the file to write.' }
711
+ if (typeof body !== 'string') throw { code: 'invalid_input', message: 'text: give what the file holds, as a string.' }
712
+ const kind = extname(path).toLowerCase()
713
+ if (!TEXT_KINDS.includes(kind)) throw { code: 'invalid_input', message: `path: only ${TEXT_KINDS.join(', ')} files are written — not "${kind || 'no extension'}".` }
714
+ if (path.split(/[\\/]/).includes('..')) throw { code: 'invalid_input', message: 'path: ".." is not taken — give a path in the scan’s folder or under it.' }
715
+ const folders = knownFolders()
716
+ if (folders.length === 0) {
717
+ throw { code: 'invalid_state', message: 'This server has opened no scan or project from a folder, so it has none to write into. Open the scan with scan_open (or a project with project_load) by its path first — its folder is where the file goes — or write the file yourself.' }
718
+ }
719
+ const target = isAbsolute(path) ? resolve(path) : resolve(folders[0], path)
720
+ const inside = (folder) => {
721
+ const r = relative(folder, target)
722
+ return r !== '' && !r.startsWith('..') && !isAbsolute(r)
723
+ }
724
+ if (!folders.some(inside)) {
725
+ throw { code: 'invalid_input', message: `path: ${target} is outside the folders a scan or a project was opened from — ${folders.join(', ')}. Give a path in one of them.` }
726
+ }
727
+ await mkdir(dirname(target), { recursive: true })
728
+ await writeFile(target, body, 'utf8')
729
+ return { path: target, size: Buffer.byteLength(body, 'utf8') }
730
+ }
731
+
732
+ // What the client was given at its last list: each tool's definition by
733
+ // name, and the page the list came from.
734
+ let listed = null
735
+ let listedFrom = null
736
+ const definition = ({ call: _call, ...tool }) => tool
737
+ // Compact, the list is the server's own tools and the page's that return
738
+ // a picture — a batch gives a picture as its text and a file, so those are called
739
+ // directly; scanruler_tools reads commands() when it is called.
740
+ const shown = (c) => !compact || Boolean(c.returnsImage)
741
+ const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir }))]
742
+ const listSource = () =>
743
+ link.page?.commands?.length ? `the page, ScanRuler ${link.page.version ?? 'of no version'}` : lastPage ? lastPage.from : `this server’s own list (scanruler-mcp ${version})`
744
+
745
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
746
+ // A tab that was paired before is likely open and on its way back: a
747
+ // client that lists once, at its start, gets its list if it comes soon.
748
+ if (!headless && !link.connected && lastPage && listWaitMs > 0) await link.waitForPage(listWaitMs)
749
+ const tools = listNow()
750
+ listed = new Map(tools.map((t) => [t.name, JSON.stringify(t)]))
751
+ listedFrom = listSource()
752
+ staleNamed = null
753
+ trace?.write({ ev: 'list', t: trace.now(), tools: tools.length, bytes: Buffer.byteLength(JSON.stringify(tools), 'utf8') })
754
+ return { tools }
755
+ })
756
+
757
+ /** How the tools now differ from those listed: added, removed, and
758
+ * changed — the same name, another input or description. Null when
759
+ * nothing was listed yet. */
760
+ const listDiff = () => {
761
+ if (!listed) return null
762
+ const now = new Map(listNow().map((t) => [t.name, JSON.stringify(t)]))
763
+ const added = [...now.keys()].filter((n) => !listed.has(n))
764
+ const removed = [...listed.keys()].filter((n) => !now.has(n))
765
+ const changed = [...now.keys()].filter((n) => listed.has(n) && listed.get(n) !== now.get(n))
766
+ return { now: now.size, added, removed, changed, any: added.length + removed.length + changed.length > 0 }
767
+ }
768
+
769
+ // A page with other commands than were listed — plugins, another version,
770
+ // a command whose input or description changed — has the client list them
771
+ // again.
772
+ const relist = () => {
773
+ if (listDiff()?.any) server.sendToolListChanged().catch(() => {})
774
+ }
775
+ // A client told the list changed may not list it again — Claude Desktop
776
+ // runs two copies of the server, and the chat's may keep the list it got
777
+ // at its start: the status says so, with what was added, taken away and
778
+ // changed, and which page the list came from — the names once for each
779
+ // difference, counted in every answer after it.
780
+ let staleNamed = null
781
+ function staleTools() {
782
+ const diff = listDiff()
783
+ if (!diff?.any) return {}
784
+ const key = JSON.stringify([diff.added, diff.removed, diff.changed])
785
+ const named = staleNamed !== key
786
+ staleNamed = key
787
+ const names = (list) => (named ? list : list.length)
788
+ return {
789
+ tools: {
790
+ stale: true,
791
+ listed: listed.size,
792
+ now: diff.now,
793
+ ...(diff.added.length ? { added: names(diff.added) } : {}),
794
+ ...(diff.removed.length ? { removed: names(diff.removed) } : {}),
795
+ ...(diff.changed.length ? { changed: names(diff.changed) } : {}),
796
+ listedFrom,
797
+ note: named
798
+ ? 'The tools this chat was given are not the ones the ScanRuler page offers now — it changed since they were listed, and the client did not list them again. A tool or field missing from them may be there now: start a new chat to get the current tools; meanwhile scanruler_batch runs any of the page’s tools by name, and a changed tool takes what the page’s command takes now.'
799
+ : 'Still stale, as an earlier answer named them: scanruler_batch runs any of the page’s tools by name.',
800
+ },
801
+ }
802
+ }
803
+ for (const event of ['connected', 'commands']) {
804
+ link.on(event, () => {
805
+ remember()
806
+ relist()
807
+ })
808
+ }
809
+
810
+ /** One of the page's tools run on the page: the result as MCP content,
811
+ * or a failure. `batched`: a picture is written to a file — the call's
812
+ * path or a new one in outDir — and given as its text, not shown. `job`:
813
+ * the job the call is part of, which the page keeps its outcome under.
814
+ * `deferred`: more changes follow straight after it (a batch with
815
+ * build: "end"), and the page may leave their rebuild to the last.
816
+ * A `{ "$file": path }` anywhere in the arguments is that file's JSON. */
817
+ const runPageTool = async (name, given, onProgress, { timings = false, ms = false, batched = false, job, deferred = false } = {}) => {
818
+ const command = commands().find((c) => toolName(c) === name)
819
+ const needsPage = !command && headless ? link.pageCommand(name) : null
820
+ 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.` })
821
+ if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
822
+ if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
823
+ return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
824
+ }
825
+ try {
826
+ const args = await withFiles(given ?? {}, cwd)
827
+ const { input, binaryKey, file } = await inputFor(command, args, { cwd })
828
+ // A session kept before it is replaced: saved, then let go.
829
+ const savedTo = replacesSession(command) && args?.saveTo !== undefined ? await saveSession(String(args.saveTo), job) : undefined
830
+ if (savedTo !== undefined) input.discard = true
831
+ const started = Date.now()
832
+ let result = await link.run(command.name, input, binaryKey, onProgress, job, deferred)
833
+ // The folder a scan or a project was opened from, for scanruler_write.
834
+ if (binaryKey && replacesSession(command) && file) rememberFolder(file)
835
+ if (savedTo !== undefined && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, savedTo }
836
+ if (ms && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, ms: Date.now() - started }
837
+ const image = command.returnsImage && !batched ? await imageContent(result, args, { cwd, outDir }) : null
838
+ if (image) return { content: image }
839
+ // Which steps a change's build played, and how long each took, only
840
+ // for a batch asked for its timings.
841
+ if (!timings && result?.steps?.played) {
842
+ const { played: _played, ...steps } = result.steps
843
+ result = { ...result, steps }
844
+ }
845
+ const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd, outDir }) : await saveFileResult(result, args, { cwd, outDir })
846
+ return text(await fitted(saved, name, Boolean(command.exact)), Boolean(command.exact))
847
+ } catch (e) {
848
+ return failure(e)
849
+ }
850
+ }
851
+
852
+ /** The session saved as a project file at `path` (a file, or a folder
853
+ * for its own name): where it went, or null when the session held
854
+ * nothing to save. Throws, saving nothing, when the save fails. */
855
+ const saveSession = async (path, job) => {
856
+ let result
857
+ try {
858
+ result = await link.run('project.save', {}, undefined, undefined, job)
859
+ } catch (e) {
860
+ if (/nothing to save/i.test(e?.message ?? '')) return null
861
+ throw { code: e?.code ?? 'failed', message: `Nothing was replaced: the session could not be saved first — ${e?.message ?? String(e)}` }
862
+ }
863
+ const written = await saveFileResult(result, { path }, { cwd, outDir })
864
+ if (!written?.file?.path) throw { code: 'failed', message: 'Nothing was replaced: project.save gave no file to save.' }
865
+ rememberFolder(written.file.path)
866
+ return written.file.path
867
+ }
868
+
869
+ // Several of the page's tools in one call — what cuts a run of twenty
870
+ // scan_nearest calls to one round trip. Each answer is the tool's own; a
871
+ // picture comes back as the text beside it, not as an image, and is
872
+ // written to a file — the call's path, or a new one in outDir — which the
873
+ // text names.
874
+ own.push({
875
+ name: 'scanruler_batch',
876
+ title: 'Run several ScanRuler tools',
877
+ description:
878
+ `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, failed: { index, tool, error } which call) unless stopOnError is false. When the refused call is a change, then with rollback (the default) every change the batch made before it is undone, so the batch stands or falls whole: undone names the steps taken back, newest first. A refused read — section_get, scan_query, any tool that changes nothing — undoes nothing: what came before it stands (kept says so). A call refused busy is run again once the page is idle (retried: "busy" on its result). Each call that changes the session is still its own undo step. build: "end" builds a modelled part once for a run of changes to it — sketches edited one after another, say — rather than after each: every call but the last is sent deferred, and a change so sent answers before its rebuild (build: "held", its result and bodies the part as last built; a pick by index names a face of that part); the last call builds them all, and so does a read of the part, or any other kind of tool, met on the way. 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 }, and so does a batch still running at the server’s answer limit (with after, its seconds); scanruler_wait { for: "job", job } gives the batch\'s answer. A { "$file": path } anywhere in a call’s arguments is that JSON file’s contents. A tool that returns a picture is not shown here: the picture is written to a file — its call’s path, or a new file in ${outDir} — and its result gives that file.path; call the tool directly to see the picture. The server’s own tools (scanruler_status, _open, _wait, _batch, _write, _files, _tools) cannot be batched.`,
879
+ inputSchema: {
880
+ type: 'object',
881
+ properties: {
882
+ calls: {
883
+ type: 'array',
884
+ minItems: 1,
885
+ maxItems: 200,
886
+ description: 'The calls, in order — or give file instead.',
887
+ items: {
888
+ type: 'object',
889
+ properties: {
890
+ tool: { type: 'string', minLength: 1, description: 'A tool’s name, as listed — scan_nearest, element_fit.' },
891
+ arguments: { type: 'object', description: 'Its arguments, as the tool takes them; {} for none.' },
892
+ },
893
+ required: ['tool'],
894
+ additionalProperties: false,
895
+ },
896
+ },
897
+ file: { type: 'string', minLength: 1, description: `A JSON file of the calls, read here in place of calls: [{ tool, arguments }], or [{ name, input }] by the command's own name (scan.open), or either under "calls" or "steps". For a long run a script wrote, which then need not pass through the conversation. A relative path is taken from ${cwd}.` },
898
+ stopOnError: { type: 'boolean', description: 'Stop at the first refused call (the default), or run every call and report each.' },
899
+ rollback: { type: 'boolean', description: 'When the batch stops at a refused change, undo what it changed before it (the default) — or leave those changes standing (false). A refused read undoes nothing either way.' },
900
+ build: { type: 'string', enum: ['each', 'end'], description: 'each (the default): a change rebuilds the part before it answers. end: the changes build once, at the last call.' },
901
+ timings: { type: 'boolean', description: 'Each call’s ms, the batch’s total_ms, and the steps a change’s build played with their ms.' },
902
+ wait: waitIn,
903
+ },
904
+ additionalProperties: false,
905
+ },
906
+ annotations: { readOnlyHint: false, openWorldHint: false },
907
+ call: async (args, ctx) => {
908
+ let calls = Array.isArray(args?.calls) ? args.calls : []
909
+ if (args?.file !== undefined) {
910
+ if (calls.length > 0) return failure({ code: 'invalid_input', message: 'Give calls or file, not both.' })
911
+ const read = await callsFromFile(String(args.file))
912
+ if (read.error) return failure({ code: 'invalid_input', message: read.error })
913
+ calls = read.calls
914
+ }
915
+ if (calls.length === 0) return failure({ code: 'invalid_input', message: 'calls: give at least one call (or a file of them).' })
916
+ if (ctx) ctx.fields.calls = calls.length
917
+ const job = startJob('scanruler_batch', (id) => runBatch(calls, args, ctx, id), ctx, args?.wait === false)
918
+ return args?.wait === false ? queuedReply(job) : answerFor(job, ctx)
919
+ },
920
+ })
921
+
922
+ /** A batch's calls read from a JSON file — { tool, arguments } as the
923
+ * batch takes them, or { name, input } by the command's own name, a
924
+ * list or one under "calls" or "steps". */
925
+ const callsFromFile = async (file) => {
926
+ const path = absolute(file, cwd)
927
+ let data
928
+ try {
929
+ data = JSON.parse(await readFile(path, 'utf8'))
930
+ } catch (e) {
931
+ return { error: `file: ${path} could not be read as JSON (${e instanceof Error ? e.message : String(e)}).` }
932
+ }
933
+ const list = Array.isArray(data) ? data : Array.isArray(data?.calls) ? data.calls : Array.isArray(data?.steps) ? data.steps : null
934
+ if (!list) return { error: `file: ${path} holds no list of calls — a list, or one under "calls" or "steps".` }
935
+ const calls = list.map((c) => (c && typeof c === 'object' && typeof c.name === 'string' && c.tool === undefined ? { tool: toolName({ name: c.name }), arguments: c.input ?? {} } : c))
936
+ if (calls.some((c) => !c || typeof c !== 'object' || typeof c.tool !== 'string')) return { error: `file: every call in ${path} needs a tool (or a name).` }
937
+ return { calls }
938
+ }
939
+
940
+ /** The batch's calls run in order — see scanruler_batch. `job` is the
941
+ * batch's, sent with each of its calls for the page to keep. */
942
+ const runBatch = async (calls, args, ctx = null, job = undefined) => {
943
+ const stopOnError = args?.stopOnError !== false
944
+ const timings = args?.timings === true
945
+ // Built at the end: every call but the last sent deferred.
946
+ const atEnd = args?.build === 'end'
947
+ let lastDeferred = false
948
+ const commandOf = (tool) => commands().find((x) => toolName(x) === tool)
949
+ const changes = (tool) => Boolean(commandOf(tool)) && !commandOf(tool).readOnly
950
+ // Where the history stood, to go back to when a change is refused —
951
+ // only when a call may change something, and a page that cannot mark
952
+ // its history simply has its batch left as it went.
953
+ let checkpoint = null
954
+ if (args?.rollback !== false && stopOnError && calls.some((c) => changes(String(c?.tool ?? '')))) {
955
+ checkpoint = await link
956
+ .run('history.checkpoint', {}, undefined, undefined, job)
957
+ .then((r) => r?.checkpoint ?? null)
958
+ .catch(() => null)
959
+ }
960
+ const results = []
961
+ let stopped
962
+ let failed
963
+ // A batch holding an exact answer (a plane's frame, say) keeps
964
+ // every answer exact: the rounding is one pass over the whole.
965
+ let exact = false
966
+ const started = Date.now()
967
+ const errorOf = (reply) => {
968
+ const body = reply.content.find((x) => x.type === 'text')?.text ?? ''
969
+ const m = /^([a-z_]+): ([\s\S]*)$/.exec(body)
970
+ return m ? { code: m[1], message: m[2] } : { code: 'failed', message: body }
971
+ }
972
+ for (let i = 0; i < calls.length; i++) {
973
+ const c = calls[i] ?? {}
974
+ const tool = String(c.tool ?? '')
975
+ const at = Date.now()
976
+ const { wait: _wait, timings: _timings, ...callArgs } = c.arguments ?? {}
977
+ const hiddenAt = hiddenNow()
978
+ const deferred = atEnd && i < calls.length - 1
979
+ lastDeferred = deferred
980
+ const run = () => runPageTool(tool, callArgs, undefined, { timings, batched: true, job, deferred })
981
+ let one = own.some((t) => t.name === tool) ? failure({ code: 'invalid_input', message: `${tool} is the server’s own and is not batched — call it on its own.` }) : await run()
982
+ // Busy with something of the page's own — a map being worked out,
983
+ // say: once it is idle, the call is run again, once.
984
+ let retried
985
+ if (one.isError && errorOf(one).code === 'busy' && commandOf(tool)) {
986
+ await pageIdle(Date.now() + IDLE_WAIT_MS)
987
+ one = await run()
988
+ retried = 'busy'
989
+ }
990
+ if (ctx && trace) {
991
+ trace.write({ ev: 'entry', call: ctx.call, index: i, t: at, tool, ms: Date.now() - at, in: jsonBytes(callArgs), ...replyMeasure(one), hidden: [hiddenAt, hiddenNow()], ...(retried ? { retried } : {}), ...(trace.args ? { args: callArgs } : {}) })
992
+ }
993
+ const ms = timings ? { ms: Date.now() - at } : {}
994
+ const again = retried ? { retried } : {}
995
+ if (!one.isError && commandOf(tool)?.exact) exact = true
996
+ if (one.isError) {
997
+ const error = errorOf(one)
998
+ results.push({ tool, ok: false, error, ...again, ...ms })
999
+ if (stopOnError) {
1000
+ stopped = i
1001
+ failed = { index: i, tool, error }
1002
+ break
1003
+ }
1004
+ } else {
1005
+ const body = one.content.find((x) => x.type === 'text')?.text ?? ''
1006
+ let parsed
1007
+ try {
1008
+ parsed = JSON.parse(body)
1009
+ } catch {
1010
+ parsed = body
1011
+ }
1012
+ results.push({ tool, ok: true, result: parsed, ...again, ...ms })
1013
+ }
1014
+ }
1015
+ // A refused change: what the batch did before it taken back, newest
1016
+ // first, once the page is idle — a rebuild still landing would refuse
1017
+ // the undo. A refused read leaves it standing.
1018
+ const rollback = Boolean(failed && checkpoint && changes(failed.tool))
1019
+ let undone
1020
+ let rollbackError
1021
+ if (rollback) {
1022
+ await pageIdle(Date.now() + IDLE_WAIT_MS)
1023
+ try {
1024
+ undone = (await link.run('history.restore', { checkpoint }, undefined, undefined, job))?.undone ?? []
1025
+ } catch (e) {
1026
+ rollbackError = `${e?.code ?? 'failed'}: ${e?.message ?? String(e)}`
1027
+ }
1028
+ }
1029
+ // Stopped before its last call, a batch built at the end leaves no
1030
+ // rebuild held: the page, read for idle, builds what is.
1031
+ if (atEnd && lastDeferred && !rollback) await pageIdle(Date.now() + IDLE_WAIT_MS)
1032
+ const kept = failed && checkpoint && !rollback ? { kept: `${failed.tool} changes nothing, so its refusal undid nothing: what the batch changed before it stands.` } : {}
1033
+ const answer = {
1034
+ results,
1035
+ ran: results.length,
1036
+ of: calls.length,
1037
+ ...(stopped !== undefined ? { stopped } : {}),
1038
+ ...(failed ? { failed, ...(rollback ? { undone: undone ?? [] } : {}), ...kept, ...(rollbackError ? { rollbackError } : {}) } : {}),
1039
+ ...(timings ? { total_ms: Date.now() - started } : {}),
1040
+ }
1041
+ // Each call's result is capped already; many together may still be
1042
+ // past it — then the whole goes to a file, and the answer keeps how
1043
+ // the batch ended.
1044
+ return text(await fitted(answer, 'scanruler_batch', exact, ['failed', 'undone']), exact)
1045
+ }
1046
+
1047
+ // The page's tools for a client that does not list them: a line each,
1048
+ // or the definitions of some — each as the full list would give it.
1049
+ if (compact) {
1050
+ own.push({
1051
+ name: 'scanruler_tools',
1052
+ title: 'List the ScanRuler tools',
1053
+ 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.`,
1054
+ inputSchema: {
1055
+ type: 'object',
1056
+ properties: {
1057
+ names: {
1058
+ type: 'array',
1059
+ items: { type: 'string', minLength: 1 },
1060
+ maxItems: MAX_DEFINITIONS,
1061
+ description: 'Tool names, as the list gives them — element_fit, scan_nearest. Leave out for the list.',
1062
+ },
1063
+ },
1064
+ additionalProperties: false,
1065
+ },
1066
+ annotations: { readOnlyHint: true, openWorldHint: false },
1067
+ call: async (args) => {
1068
+ const asked = typeof args?.names === 'string' ? args.names.split(/[\s,]+/) : Array.isArray(args?.names) ? args.names.map(String) : []
1069
+ const names = [...new Set(asked.filter(Boolean))]
1070
+ const list = commands()
1071
+ if (names.length === 0) {
1072
+ return text({
1073
+ count: list.length,
1074
+ tools: Object.fromEntries(list.map((c) => [toolName(c), summary(c)])),
1075
+ next: 'scanruler_tools with names gives a tool’s definition; scanruler_batch runs it.',
1076
+ })
1077
+ }
1078
+ if (names.length > MAX_DEFINITIONS) {
1079
+ return failure({ code: 'invalid_input', message: `names: at most ${MAX_DEFINITIONS} at a time — ask for the rest in another call.` })
1080
+ }
1081
+ const found = []
1082
+ const unknown = []
1083
+ for (const name of names) {
1084
+ const command = list.find((c) => toolName(c) === name)
1085
+ const mine = own.find((t) => t.name === name)
1086
+ if (command) found.push(toolFor(command, { outDir }))
1087
+ else if (mine) found.push(definition(mine))
1088
+ else unknown.push(name)
1089
+ }
1090
+ // Exact: a schema's numbers are limits and defaults, not readings.
1091
+ return text({ tools: found, unknown }, true)
1092
+ },
1093
+ })
1094
+ }
1095
+
1096
+ // Each call traced as one line when it is answered — what the client
1097
+ // waited for — with the tab's hidden state at its start and end and the
1098
+ // jobs running when it came; a batch's entries and a queued job's end get
1099
+ // lines of their own.
1100
+ let callNo = 0
1101
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1102
+ if (!trace) return dispatch(request, extra, null)
1103
+ const { name, arguments: args } = request.params
1104
+ const ctx = { call: ++callNo, fields: {} }
1105
+ const t = trace.now()
1106
+ const hiddenAt = hiddenNow()
1107
+ const running = runningJobs()
1108
+ const reply = await dispatch(request, extra, ctx)
1109
+ trace.write({
1110
+ ev: 'call',
1111
+ call: ctx.call,
1112
+ t,
1113
+ tool: name,
1114
+ ms: trace.now() - t,
1115
+ in: jsonBytes(args ?? {}),
1116
+ ...replyMeasure(reply),
1117
+ hidden: [hiddenAt, hiddenNow()],
1118
+ ...(running ? { running } : {}),
1119
+ ...ctx.fields,
1120
+ ...(trace.args ? { args: args ?? {} } : {}),
1121
+ })
1122
+ return reply
1123
+ })
1124
+
1125
+ /** A tool call answered — see the handler above. */
1126
+ const dispatch = async (request, extra, ctx) => {
1127
+ const { name, arguments: args } = request.params
1128
+ const mine = own.find((t) => t.name === name)
1129
+ if (mine) return mine.call(args, ctx)
1130
+ // Progress lines from the page, for a client that asked for them.
1131
+ const token = request.params._meta?.progressToken
1132
+ let step = 0
1133
+ const onProgress =
1134
+ token === undefined
1135
+ ? undefined
1136
+ : (line) => {
1137
+ extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
1138
+ }
1139
+ // A change runs as a job, and so does a long read: answered when done —
1140
+ // or with the job, at once for wait: false and at the answer limit
1141
+ // otherwise — and kept either way, for a client that stops waiting
1142
+ // before it is done.
1143
+ const command = commands().find((c) => toolName(c) === name)
1144
+ if (!command || (command.readOnly && !command.long)) return runPageTool(name, args, onProgress)
1145
+ const { wait, timings, ...rest } = args ?? {}
1146
+ const timed = timings === true && !command.readOnly
1147
+ const job = startJob(name, (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id }), ctx, wait === false)
1148
+ return wait === false ? queuedReply(job) : answerFor(job, ctx)
1149
+ }
1150
+
1151
+ trace?.write({ ev: 'start', t: trace.now(), version, backend: headless ? 'headless' : 'page', tools: toolMode, pid: process.pid, args: trace.args })
1152
+ link.on?.('visibility', (hidden) => trace?.write({ ev: 'visibility', t: trace.now(), hidden }))
1153
+
1154
+ return server
1155
+ }