scanruler-mcp 0.4.0 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -8
- package/package.json +14 -3
- package/src/commands.json +257 -60
- package/src/config.js +24 -2
- package/src/link.js +1 -1
- package/src/main.js +3 -3
- package/src/mcp.js +413 -63
- package/src/relay.js +10 -0
- package/src/tools.js +75 -35
package/src/mcp.js
CHANGED
|
@@ -24,7 +24,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
|
24
24
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
25
25
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
26
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'
|
|
27
|
+
import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, relativeFrom, replacesSession, saveFileResult, toolFor, toolName, waitIn, withFiles, writeNew } from './tools.js'
|
|
28
28
|
import { jsonBytes, replyMeasure } from './trace.js'
|
|
29
29
|
|
|
30
30
|
/** The app's own commands as of this release, so the tools are there before
|
|
@@ -117,6 +117,96 @@ function summary(command) {
|
|
|
117
117
|
return /^.*?[.!?](?= |$)/.exec(description)?.[0] ?? description
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** The names a call would give new things, at the fields its command says
|
|
121
|
+
* name them — `names: { space, at }` in the page's list, `at` paths into
|
|
122
|
+
* the input ("feature.name", "features[].name", a `[]` stepping into each
|
|
123
|
+
* item of a list) and `space` the things whose names may not repeat, in
|
|
124
|
+
* words. Each `{ space, name }`; none for a command that says nothing. */
|
|
125
|
+
export function namesGiven(command, args) {
|
|
126
|
+
const declared = command?.names
|
|
127
|
+
if (!declared || !Array.isArray(declared.at)) return []
|
|
128
|
+
const space = String(declared.space ?? 'names')
|
|
129
|
+
const found = []
|
|
130
|
+
const walk = (value, steps) => {
|
|
131
|
+
if (value === null || typeof value !== 'object') {
|
|
132
|
+
if (steps.length === 0 && typeof value === 'string' && value.trim()) found.push({ space, name: value })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (steps.length === 0) return
|
|
136
|
+
const [step, ...rest] = steps
|
|
137
|
+
const list = step.endsWith('[]')
|
|
138
|
+
const next = value[list ? step.slice(0, -2) : step]
|
|
139
|
+
if (!list) walk(next, rest)
|
|
140
|
+
else if (Array.isArray(next)) for (const item of next) walk(item, rest)
|
|
141
|
+
}
|
|
142
|
+
for (const path of declared.at) walk(args ?? {}, String(path).split('.'))
|
|
143
|
+
return found
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Why a batch's calls may not run together: two of them — or one twice —
|
|
147
|
+
* giving new things the same name in one space, trimmed and in any case,
|
|
148
|
+
* which the page refuses at the second, rolling back every change before
|
|
149
|
+
* it. Null when none do. `commandOf` finds a call's command by its tool. */
|
|
150
|
+
export function nameClash(calls, commandOf) {
|
|
151
|
+
const seen = new Map()
|
|
152
|
+
for (let i = 0; i < calls.length; i++) {
|
|
153
|
+
const tool = String(calls[i]?.tool ?? '')
|
|
154
|
+
for (const { space, name } of namesGiven(commandOf(tool), calls[i]?.arguments)) {
|
|
155
|
+
const key = `${space}\u0000${name.trim().toLowerCase()}`
|
|
156
|
+
const first = seen.get(key)
|
|
157
|
+
if (first) {
|
|
158
|
+
const which = first.index === i ? `calls[${i}] (${tool}) gives the name “${name.trim()}” twice` : `calls[${first.index}] (${first.tool}) and calls[${i}] (${tool}) both give the name “${name.trim()}”${first.name.trim() !== name.trim() ? ` (“${first.name.trim()}”)` : ''}`
|
|
159
|
+
return `${which} — names are unique across ${space}, so the page would refuse the second and the batch roll back what came before it. Nothing was run: give each its own name.`
|
|
160
|
+
}
|
|
161
|
+
seen.set(key, { index: i, tool, name })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A batch's calls as the client sent them — `{ calls }`, or `{ error }`
|
|
168
|
+
* when they cannot be read. Some clients send a large nested value as a
|
|
169
|
+
* string of its JSON: the list of calls, a call, or a call's arguments
|
|
170
|
+
* given so is read as that JSON. Anything else that is not a list is no
|
|
171
|
+
* calls, as before. */
|
|
172
|
+
export function callsGiven(given) {
|
|
173
|
+
const parse = (text) => {
|
|
174
|
+
if (!text.trim()) return { value: undefined }
|
|
175
|
+
try {
|
|
176
|
+
return { value: JSON.parse(text) }
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return { error: e instanceof Error ? e.message : String(e) }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const SHAPE = '[{ "tool": "…", "arguments": { … } }]'
|
|
182
|
+
let list = given
|
|
183
|
+
if (typeof given === 'string') {
|
|
184
|
+
const read = parse(given)
|
|
185
|
+
if (read.error) return { error: `calls came as a string that is not JSON (${read.error}) — give calls as a list: ${SHAPE}.` }
|
|
186
|
+
list = Array.isArray(read.value?.calls) ? read.value.calls : read.value
|
|
187
|
+
if (!Array.isArray(list)) return { error: `calls came as a string whose JSON is not a list of calls — give calls as a list: ${SHAPE}.` }
|
|
188
|
+
}
|
|
189
|
+
if (!Array.isArray(list)) return { calls: [] }
|
|
190
|
+
const calls = []
|
|
191
|
+
for (let i = 0; i < list.length; i++) {
|
|
192
|
+
let c = list[i]
|
|
193
|
+
if (typeof c === 'string') {
|
|
194
|
+
const read = parse(c)
|
|
195
|
+
if (read.error || !read.value || typeof read.value !== 'object' || Array.isArray(read.value)) return { error: `calls[${i}] came as a string that is not a call's JSON${read.error ? ` (${read.error})` : ''} — each call is { "tool": "…", "arguments": { … } }. Nothing was run.` }
|
|
196
|
+
c = read.value
|
|
197
|
+
}
|
|
198
|
+
if (c && typeof c === 'object' && typeof c.arguments === 'string') {
|
|
199
|
+
const read = parse(c.arguments)
|
|
200
|
+
const tool = typeof c.tool === 'string' ? ` (${c.tool})` : ''
|
|
201
|
+
if (read.error) return { error: `calls[${i}].arguments${tool} came as a string that is not JSON (${read.error}) — give them as an object, {} for none. Nothing was run.` }
|
|
202
|
+
if (read.value !== undefined && (read.value === null || typeof read.value !== 'object' || Array.isArray(read.value))) return { error: `calls[${i}].arguments${tool} came as a string whose JSON is not an object — give them as an object, {} for none. Nothing was run.` }
|
|
203
|
+
c = { ...c, arguments: read.value ?? {} }
|
|
204
|
+
}
|
|
205
|
+
calls.push(c)
|
|
206
|
+
}
|
|
207
|
+
return { calls }
|
|
208
|
+
}
|
|
209
|
+
|
|
120
210
|
/** What the server says it is when it runs ScanRuler itself, headless. */
|
|
121
211
|
const HEADLESS_ABOUT = [
|
|
122
212
|
'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").',
|
|
@@ -174,6 +264,27 @@ const LIST_WAIT_MS = 3_000
|
|
|
174
264
|
* says where, how large and what it holds. */
|
|
175
265
|
export const ANSWER_CAP = 40_000
|
|
176
266
|
|
|
267
|
+
/** Commands whose answer may run past ANSWER_CAP before it is written to a
|
|
268
|
+
* file, and how far. A guide is read whole, before a task's first step,
|
|
269
|
+
* and is worth its room: several asked for together are answered while
|
|
270
|
+
* they fit some 80 k characters, the rest named to read next (the page's
|
|
271
|
+
* guide.read), so a read does not reach this. 90 k stays under Claude Code's
|
|
272
|
+
* default limit on a tool's answer, 25 000 tokens (MAX_MCP_OUTPUT_TOKENS)
|
|
273
|
+
* — some 100 k characters at four a token, less for text with many
|
|
274
|
+
* escapes and marks. */
|
|
275
|
+
export const ANSWER_CAPS = { 'guide.read': 90_000 }
|
|
276
|
+
|
|
277
|
+
/** What a spilled answer of a command says to ask for next time: less of
|
|
278
|
+
* it, by the command's own fields — the command's own word (askForLess,
|
|
279
|
+
* which a page sends with its commands) first, else this, for a page from
|
|
280
|
+
* before it did. */
|
|
281
|
+
const ASK_FOR_LESS = {
|
|
282
|
+
'guide.read': 'one section at a time (section, by its heading — a ### part of a long one too; guide_read with no topic lists them with what each costs)',
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** What a command's spilled answer says to ask for next time. */
|
|
286
|
+
const lessOf = (command) => (typeof command?.askForLess === 'string' && command.askForLess ? command.askForLess : ASK_FOR_LESS[command?.name])
|
|
287
|
+
|
|
177
288
|
/** How many jobs the status lists besides those running, the newest. */
|
|
178
289
|
const JOBS_LISTED = 5
|
|
179
290
|
|
|
@@ -185,6 +296,18 @@ const FILES_LISTED = 300
|
|
|
185
296
|
|
|
186
297
|
const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)))
|
|
187
298
|
|
|
299
|
+
/** The notes an answer carries for the agent (`notes`), when it is an
|
|
300
|
+
* object that has them. */
|
|
301
|
+
const notesIn = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) && v.notes !== undefined && v.notes !== null ? v.notes : undefined)
|
|
302
|
+
|
|
303
|
+
/** `d` as local time with its offset from UTC, to the second —
|
|
304
|
+
* 2026-10-23T18:04:05+02:00 — for an agent whose host gives it no clock. */
|
|
305
|
+
export function localTime(d = new Date()) {
|
|
306
|
+
const pad = (n) => String(Math.trunc(Math.abs(n))).padStart(2, '0')
|
|
307
|
+
const off = -d.getTimezoneOffset()
|
|
308
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${off < 0 ? '-' : '+'}${pad(off / 60)}:${pad(off % 60)}`
|
|
309
|
+
}
|
|
310
|
+
|
|
188
311
|
const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
|
|
189
312
|
const failure = (e) => {
|
|
190
313
|
const code = e?.code ?? 'failed'
|
|
@@ -214,7 +337,8 @@ const failure = (e) => {
|
|
|
214
337
|
* }} options — `tools` is how the page's commands are listed (TOOL_MODES).
|
|
215
338
|
* `listWaitMs`: how long a listing waits for a page that connected before
|
|
216
339
|
* and is not back yet. `answerCap`: the most characters an answer
|
|
217
|
-
* carries; a longer one goes to a file in
|
|
340
|
+
* carries; a longer one goes to a file in the folder the scan open now
|
|
341
|
+
* came from, else in `outDir` (ANSWER_CAP).
|
|
218
342
|
* `trace`, when given, gets a line per call (trace.js).
|
|
219
343
|
* `answerWithinMs` is how long a job's call is held before it answers
|
|
220
344
|
* with the job, and the most scanruler_wait waits; 0 holds it to the end.
|
|
@@ -290,7 +414,7 @@ export function createMcpServer({
|
|
|
290
414
|
// line, and one answered before it was done (queued) gets a line when it
|
|
291
415
|
// is. `run` is handed the job's name, for the page.
|
|
292
416
|
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 }
|
|
417
|
+
const job = { job: `j${nextJob++}-${copyMark}`, tool, state: 'running', started: Date.now(), ms: null, reply: null, queued, progress: null }
|
|
294
418
|
if (ctx) Object.assign(ctx.fields, { job: job.job, ...(queued ? { queued: true } : {}) })
|
|
295
419
|
const finished = (reply) => {
|
|
296
420
|
job.state = reply.isError ? 'failed' : 'done'
|
|
@@ -312,7 +436,25 @@ export function createMcpServer({
|
|
|
312
436
|
}
|
|
313
437
|
return job
|
|
314
438
|
}
|
|
315
|
-
const jobBrief = (j) => ({
|
|
439
|
+
const jobBrief = (j) => ({
|
|
440
|
+
job: j.job,
|
|
441
|
+
tool: j.tool,
|
|
442
|
+
state: j.state,
|
|
443
|
+
...(j.ms !== null ? { ms: j.ms } : { runningMs: Date.now() - j.started, ...(j.progress ? { progress: j.progress } : {}) }),
|
|
444
|
+
})
|
|
445
|
+
/** A job's last progress line from the page kept — what its status line
|
|
446
|
+
* says, "Measuring deviation — 42%…" — as `{ phase, percent }`, percent
|
|
447
|
+
* null when the line gives none; `call`, in a batch, the index of the
|
|
448
|
+
* call it came from. The status and scanruler_wait answer it while the
|
|
449
|
+
* job runs, whether or not the client asked for progress. */
|
|
450
|
+
const noteProgress = (job, line, call) => {
|
|
451
|
+
if (!job) return
|
|
452
|
+
const text = String(line ?? '').trim()
|
|
453
|
+
if (!text) return
|
|
454
|
+
const m = /(\d+(?:\.\d+)?)\s*%/.exec(text)
|
|
455
|
+
const phase = m ? text.replace(/\s*[—–-]?\s*\d+(?:\.\d+)?\s*%\s*/, ' ').replace(/\s+(?=[…:.,]|$)/g, '').trim() : text
|
|
456
|
+
job.progress = { phase: phase || text, percent: m ? Number(m[1]) : null, ...(call !== undefined ? { call } : {}) }
|
|
457
|
+
}
|
|
316
458
|
/** A job's answer as scanruler_wait gives it: the tool's own answer
|
|
317
459
|
* under result, or its refusal under error. */
|
|
318
460
|
const jobAnswer = (j) => {
|
|
@@ -356,9 +498,14 @@ export function createMcpServer({
|
|
|
356
498
|
/** Whether the page is done with what it is busy with — polled until
|
|
357
499
|
* `until` (ms since the epoch). A page that does not say is taken as
|
|
358
500
|
* idle. */
|
|
501
|
+
/** What the page was busy with when pageIdle last looked — its busy
|
|
502
|
+
* sentence (a build's step in play and how long it has run, say), and
|
|
503
|
+
* the change running and for how long — or null. */
|
|
504
|
+
let pageBusy = null
|
|
359
505
|
const pageIdle = async (until) => {
|
|
360
506
|
for (;;) {
|
|
361
507
|
const state = await link.run('session.state', {}).catch(() => null)
|
|
508
|
+
pageBusy = state && typeof state === 'object' ? { busy: state.busy ?? null, running: state.running ?? null, runningMs: state.runningMs ?? null } : null
|
|
362
509
|
if (!state || typeof state !== 'object' || (state.busy == null && state.running == null)) return true
|
|
363
510
|
if (Date.now() >= until) return false
|
|
364
511
|
await sleep(Math.min(500, until - Date.now()))
|
|
@@ -423,20 +570,106 @@ export function createMcpServer({
|
|
|
423
570
|
const startedAt = new Date().toISOString()
|
|
424
571
|
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
572
|
|
|
426
|
-
/**
|
|
427
|
-
*
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const sessionBrief = async () => {
|
|
431
|
-
if (!link.connected) return {}
|
|
573
|
+
/** The page's session.state, or null when there is no page or it does
|
|
574
|
+
* not answer soon. */
|
|
575
|
+
const sessionRead = async () => {
|
|
576
|
+
if (!link.connected) return null
|
|
432
577
|
let timer
|
|
433
578
|
const state = await Promise.race([
|
|
434
579
|
link.run('session.state', {}).catch(() => null),
|
|
435
580
|
new Promise((r) => (timer = setTimeout(() => r(null), SESSION_WITHIN_MS))),
|
|
436
581
|
])
|
|
437
582
|
clearTimeout(timer)
|
|
438
|
-
|
|
583
|
+
return state && typeof state === 'object' ? state : null
|
|
584
|
+
}
|
|
585
|
+
/** Whether a session holds work: a scan, or anything counted. */
|
|
586
|
+
const holdsWork = (state) => Boolean(state?.scan) || Object.values(state?.project?.holds ?? {}).some((n) => typeof n === 'number' && n > 0)
|
|
587
|
+
|
|
588
|
+
// ---- the scan open now ----------------------------------------------------
|
|
589
|
+
// The folder the scan open in the page came from, when this server opened
|
|
590
|
+
// it — or the project holding it — from a file: the part's own folder,
|
|
591
|
+
// where an answer too long to carry and a batch's picture are written.
|
|
592
|
+
// Not the latest of the folders: a scan_open that saves the session it
|
|
593
|
+
// replaces (saveTo) puts the previous part's folder there, and a scan
|
|
594
|
+
// opened by hand puts nothing. Forgotten when a session is replaced
|
|
595
|
+
// without a file or the page reloads; and the page holding a scan of
|
|
596
|
+
// another name now (opened by hand) holds another part.
|
|
597
|
+
let openScan = null
|
|
598
|
+
/** What a session-replacing call leaves open: its file's folder and the
|
|
599
|
+
* scan's name as the page answered it, or nothing known. */
|
|
600
|
+
const openedFrom = (file, result) => {
|
|
601
|
+
openScan = file ? { folder: dirname(file), scan: typeof result?.scan?.fileName === 'string' ? result.scan.fileName : null } : null
|
|
602
|
+
}
|
|
603
|
+
/** The folder of the scan open now, or null when it was not opened from
|
|
604
|
+
* one here. A page that does not answer soon is taken to hold it still. */
|
|
605
|
+
const scanFolderNow = async () => {
|
|
606
|
+
if (!openScan) return null
|
|
607
|
+
const kept = openScan
|
|
608
|
+
const state = await sessionRead()
|
|
609
|
+
if (state && kept.scan !== null && (state.scan?.fileName ?? null) !== kept.scan) return null
|
|
610
|
+
return kept.folder
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---- the page that came back ----------------------------------------------
|
|
614
|
+
// A page names itself — pageId, new each time it loads — and says when it
|
|
615
|
+
// loaded (since). Another name where the last page held work is a reload
|
|
616
|
+
// (Vite's after an edit, the person's, a tab the browser unloaded), and
|
|
617
|
+
// what that page held is gone: the next answer says so, with the project
|
|
618
|
+
// last opened or saved here, before a no_scan is read as a session never
|
|
619
|
+
// had. A page from before pageId is not told apart. `stale`: what the
|
|
620
|
+
// page said when it could not load a part of the app (stale_page).
|
|
621
|
+
let pageSeen = null
|
|
622
|
+
let reloaded = null
|
|
623
|
+
const seePage = (connected) => {
|
|
624
|
+
if (!link.page) return
|
|
625
|
+
const id = typeof link.page.pageId === 'string' && link.page.pageId ? link.page.pageId : null
|
|
626
|
+
if (pageSeen && (id === null ? !connected : pageSeen.id === id)) return
|
|
627
|
+
if (id !== null && pageSeen?.id && pageSeen.held) {
|
|
628
|
+
const r = { since: typeof link.page.since === 'string' ? link.page.since : null, empty: null }
|
|
629
|
+
reloaded = r
|
|
630
|
+
// Whether the new page holds anything, read once while it is new.
|
|
631
|
+
sessionRead().then((state) => {
|
|
632
|
+
if (state) r.empty = !holdsWork(state)
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
// Another page holds nothing this server opened.
|
|
636
|
+
if (pageSeen) openScan = null
|
|
637
|
+
pageSeen = { id, held: false, stale: null }
|
|
638
|
+
}
|
|
639
|
+
/** The note a reload is told by, once. */
|
|
640
|
+
const reloadNote = ({ since, empty }) => {
|
|
641
|
+
const at = since && Number.isFinite(Date.parse(since)) ? ` at ${localTime(new Date(since)).slice(11, 16)}` : ''
|
|
642
|
+
const now = empty === true ? ' and holds nothing now' : empty === false ? ' — a new page, holding work of its own' : ''
|
|
643
|
+
const project = knownProject()
|
|
644
|
+
return (
|
|
645
|
+
`The ScanRuler page reloaded${at}${now}: what the page held before is gone, unless it was saved. ` +
|
|
646
|
+
(project ? `The last project opened or saved here was ${project} (project_load opens it).` : 'No project was opened or saved through this server.')
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
/** A reply with the reload said in it, when one is to be said: in an
|
|
650
|
+
* object answer as pageReloaded, first; after a refusal's message. */
|
|
651
|
+
const withReload = (reply) => {
|
|
652
|
+
if (!reloaded || !link.connected) return reply
|
|
653
|
+
const block = reply?.content?.find((x) => x.type === 'text')
|
|
654
|
+
if (!block) return reply
|
|
655
|
+
const note = reloadNote(reloaded)
|
|
656
|
+
reloaded = null
|
|
657
|
+
const body = block.text.trimStart()
|
|
658
|
+
const said = reply.isError || !body.startsWith('{') ? `${block.text} — ${note}` : `{"pageReloaded":${JSON.stringify(note)}${/^\{\s*\}$/.test(body) ? '}' : `,${body.slice(1)}`}`
|
|
659
|
+
// A copy: a job keeps its reply as it was, for scanruler_wait.
|
|
660
|
+
return { ...reply, content: reply.content.map((x) => (x === block ? { ...x, text: said } : x)) }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** What the page's session holds, in brief, for the status: the scan,
|
|
664
|
+
* the project's name, whether it has unsaved changes, and its work
|
|
665
|
+
* counted — read off session.state, and left out when the page does not
|
|
666
|
+
* answer soon. */
|
|
667
|
+
const sessionBrief = async () => {
|
|
668
|
+
const state = await sessionRead()
|
|
669
|
+
if (!state) return {}
|
|
439
670
|
const project = state.project ?? {}
|
|
671
|
+
if (pageSeen && holdsWork(state)) pageSeen.held = true
|
|
672
|
+
if (pageSeen && typeof state.stale?.note === 'string') pageSeen.stale = state.stale.note
|
|
440
673
|
return {
|
|
441
674
|
session: {
|
|
442
675
|
scan: state.scan?.fileName ?? null,
|
|
@@ -444,41 +677,72 @@ export function createMcpServer({
|
|
|
444
677
|
unsaved: project.unsaved ?? null,
|
|
445
678
|
...(project.holds ?? {}),
|
|
446
679
|
},
|
|
447
|
-
// The page's word that
|
|
448
|
-
|
|
680
|
+
// The page's word that the server it is connected to is older than its
|
|
681
|
+
// commands assume — while relaying, that is the holding copy.
|
|
682
|
+
...(typeof state.serverTooOld === 'string' ? { serverTooOld: tooOldHere(state.serverTooOld) } : {}),
|
|
449
683
|
}
|
|
450
684
|
}
|
|
451
685
|
|
|
686
|
+
/** The page judges the copy it is connected to. A relaying copy is not
|
|
687
|
+
* that one: say which is out of date, and that quitting its app is the
|
|
688
|
+
* fix — updating this one changes nothing. */
|
|
689
|
+
const tooOldHere = (said) => {
|
|
690
|
+
const info = serverInfo()
|
|
691
|
+
if (info.role !== 'relay') return said
|
|
692
|
+
const holder = info.holder?.version ? `version ${info.holder.version}` : 'from before 0.4.1'
|
|
693
|
+
return (
|
|
694
|
+
`This server (${info.version}) is current; it sends its calls through another copy of scanruler-mcp, ${holder}, which holds the tab’s connection and is the one out of date. ` +
|
|
695
|
+
'Another AI app or chat started it — quit that app completely (Claude Desktop, Claude Code, Codex or another), and this copy takes the connection over within about ten seconds; or update that app’s setting as the page says: ' +
|
|
696
|
+
said
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
452
700
|
// ---- answers that fit -----------------------------------------------------
|
|
453
701
|
// An answer past answerCap — a read with every point in it, a batch of
|
|
454
702
|
// many — would crowd out the agent's context: it is written whole to a
|
|
455
|
-
// file
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
703
|
+
// file, and the answer says where, how large and what it holds. The file
|
|
704
|
+
// goes into the folder the scan open now came from (scanFolderNow) — a
|
|
705
|
+
// sandboxed agent can open that one, not the server's outDir — else, or
|
|
706
|
+
// when that folder takes no file, into outDir.
|
|
707
|
+
/** The most characters a command's answer carries (ANSWER_CAPS), never
|
|
708
|
+
* under answerCap. */
|
|
709
|
+
const capOf = (command) => Math.max(answerCap, ANSWER_CAPS[command?.name] ?? 0)
|
|
710
|
+
/** `value` as an answer gives it: itself, or — its JSON past `cap` —
|
|
711
|
+
* `{ truncated, file, bytes, keys, brief }`, brief its short fields,
|
|
712
|
+
* those named in `keep` and its notes. `tool` names the file, numbered
|
|
713
|
+
* past one there (writeNew), so answers spilled at once never share it;
|
|
714
|
+
* `less` says what to ask for next time. A file that cannot be written
|
|
715
|
+
* leaves the answer whole. */
|
|
716
|
+
const fitted = async (value, tool, exact = false, keep = [], { cap = answerCap, less = 'fields, limit' } = {}) => {
|
|
461
717
|
const body = agentJson(value, exact)
|
|
462
|
-
if (body.length <=
|
|
718
|
+
if (body.length <= cap) return value
|
|
463
719
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
464
|
-
|
|
720
|
+
const name = `${tool}-${stamp}.json`
|
|
721
|
+
const scanFolder = await scanFolderNow()
|
|
722
|
+
let file
|
|
723
|
+
let where
|
|
465
724
|
try {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
await
|
|
725
|
+
file = scanFolder ? await writeNew(scanFolder, name, body).catch(() => null) : null
|
|
726
|
+
where = file ? `It is in ${scanFolder}, the folder the scan open now was opened from.` : `It is in ${outDir}, the server's output folder${scanFolder ? ` — ${scanFolder}, the scan's folder, took no file` : ': the scan open now was not opened from a folder through this server'}.`
|
|
727
|
+
file ??= await writeNew(outDir, name, body)
|
|
469
728
|
} catch {
|
|
470
729
|
return value
|
|
471
730
|
}
|
|
472
731
|
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)
|
|
732
|
+
const short = (k, v) => keep.includes(k) || k === 'notes' || v === null || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 200)
|
|
474
733
|
const brief = object ? Object.fromEntries(Object.entries(value).filter(([k, v]) => short(k, v))) : {}
|
|
734
|
+
// The notes the person left for the agent ride in every brief, where
|
|
735
|
+
// the answer gave them or one level down (a job's result, a
|
|
736
|
+
// workspace's part of the session): a note read late is a step done
|
|
737
|
+
// over.
|
|
738
|
+
if (object) for (const [k, v] of Object.entries(value)) if (!(k in brief) && notesIn(v) !== undefined) brief[k] = { notes: notesIn(v) }
|
|
475
739
|
return {
|
|
476
740
|
truncated: true,
|
|
477
741
|
file,
|
|
478
742
|
bytes: Buffer.byteLength(body, 'utf8'),
|
|
479
743
|
...(object ? { keys: Object.keys(value) } : Array.isArray(value) ? { items: value.length } : {}),
|
|
480
744
|
...(Object.keys(brief).length ? { brief } : {}),
|
|
481
|
-
note: `The answer is ${Math.round(body.length / 1000)} k characters, past the ${Math.round(
|
|
745
|
+
note: `The answer is ${Math.round(body.length / 1000)} k characters, past the ${Math.round(cap / 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 (${less}). ${where}`,
|
|
482
746
|
}
|
|
483
747
|
}
|
|
484
748
|
|
|
@@ -506,6 +770,35 @@ export function createMcpServer({
|
|
|
506
770
|
// Not kept: this copy still has it.
|
|
507
771
|
}
|
|
508
772
|
}
|
|
773
|
+
/** The folder every relative path is taken from — a batch's file, a
|
|
774
|
+
* $file, a file read, a file written: the latest of those folders, as
|
|
775
|
+
* scanruler_write and scanruler_files take it, else the folder this
|
|
776
|
+
* server started in. A client may start the server anywhere (Claude
|
|
777
|
+
* Desktop in its own install folder); the scan's folder is where the
|
|
778
|
+
* agent's files are. */
|
|
779
|
+
const pathBase = () => knownFolders()[0] ?? cwd
|
|
780
|
+
|
|
781
|
+
// The project file last opened or saved here, kept with the folders: what
|
|
782
|
+
// a page that came back empty is pointed to.
|
|
783
|
+
let lastProject = null
|
|
784
|
+
const knownProject = () => {
|
|
785
|
+
try {
|
|
786
|
+
const kept = folderStore?.loadProject?.()
|
|
787
|
+
if (kept) lastProject = kept
|
|
788
|
+
} catch {
|
|
789
|
+
// Not read: this copy's own stands.
|
|
790
|
+
}
|
|
791
|
+
return lastProject
|
|
792
|
+
}
|
|
793
|
+
const rememberProject = (file) => {
|
|
794
|
+
if (extname(String(file)).toLowerCase() !== '.scanruler') return
|
|
795
|
+
lastProject = file
|
|
796
|
+
try {
|
|
797
|
+
folderStore?.saveProject?.(file)
|
|
798
|
+
} catch {
|
|
799
|
+
// Not kept: this copy still has it.
|
|
800
|
+
}
|
|
801
|
+
}
|
|
509
802
|
|
|
510
803
|
/** The jobs as the status lists them, newest first: every one running
|
|
511
804
|
* and the newest JOBS_LISTED others; jobsMore counts the rest kept. */
|
|
@@ -529,13 +822,16 @@ export function createMcpServer({
|
|
|
529
822
|
viewport: link.page.viewport ?? null,
|
|
530
823
|
hidden: link.page.hidden ?? null,
|
|
531
824
|
commands: link.page.commands.length,
|
|
825
|
+
...(typeof link.page.since === 'string' ? { since: link.page.since } : {}),
|
|
826
|
+
...(pageSeen?.stale ? { stale: true, staleNote: pageSeen.stale } : {}),
|
|
532
827
|
}
|
|
533
828
|
: null,
|
|
534
|
-
server: { ...serverInfo(), answerWithin: holdS },
|
|
829
|
+
server: { ...serverInfo(), now: localTime(), cwd, relativeTo: pathBase(), answerWithin: holdS },
|
|
535
830
|
...(link.connected || headless ? {} : { howToConnect: connectHelp() }),
|
|
536
831
|
...(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
832
|
...(jobs.size > 0 ? jobsListed() : { jobsNote: noJobs }),
|
|
538
833
|
...(!headless && knownFolders().length ? { folders: knownFolders() } : {}),
|
|
834
|
+
...(!headless && knownProject() ? { lastProject: knownProject() } : {}),
|
|
539
835
|
...staleTools(),
|
|
540
836
|
})
|
|
541
837
|
|
|
@@ -543,10 +839,14 @@ export function createMcpServer({
|
|
|
543
839
|
{
|
|
544
840
|
name: 'scanruler_status',
|
|
545
841
|
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
|
|
842
|
+
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). page.since: when the page loaded. page.stale, when there: the page could not load a part of the app (an answer said stale_page) — it is older than the app it came from, rebuilt since: save the project, then ask the person to reload, as page.staleNote says. lastProject: the project file last opened or saved through this server — what to open again when the page reloaded (an answer then says pageReloaded). 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 running one with progress: { phase, percent } when the page has said how far it is — 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.now: the time on this computer, local, with its offset from UTC (2026-10-23T18:04:05+02:00) — the clock to date a report or time a step by. server.cwd: the folder this server started in; server.relativeTo: the folder every relative path is taken from — the latest of folders, else cwd; server.outDir: where a file goes that no path was given for — but an answer too long to carry (truncated: true, file), and a picture in a batch, go into the folder the scan open now was opened from, when it was opened here by a path. 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. server.role: holder — this copy holds the port and the tab’s connection; relay — another copy (another chat or AI app) holds them and this one sends its calls through it, the work still running in the tab, server.holder.version saying which (null: from before 0.4.1); none — neither yet, server.error saying why. serverTooOld, when there, says the server the page is connected to is older than the page needs — while relaying, the holding copy, not this one: 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
843
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
548
844
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
549
|
-
call: async () =>
|
|
845
|
+
call: async () => {
|
|
846
|
+
// The session read first: it may find the page stale.
|
|
847
|
+
const brief = await sessionBrief()
|
|
848
|
+
return text({ ...status(), ...brief })
|
|
849
|
+
},
|
|
550
850
|
},
|
|
551
851
|
{
|
|
552
852
|
name: 'scanruler_open',
|
|
@@ -567,7 +867,7 @@ export function createMcpServer({
|
|
|
567
867
|
{
|
|
568
868
|
name: 'scanruler_wait',
|
|
569
869
|
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.`,
|
|
870
|
+
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; running, with busy — what the page is at, in its words: a model's build names the step in play and how long it has run — and running, the change still running, with runningS), 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, and busy and running while running — or for a job its answer: { job, tool, state, ms, result } or { …, error: { code, message } } — state running if the time ran out first, with runningMs and, once the page has said how far it is, progress: { phase, percent } (its status line: "Measuring deviation", 42): 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
871
|
inputSchema: {
|
|
572
872
|
type: 'object',
|
|
573
873
|
properties: {
|
|
@@ -612,7 +912,10 @@ export function createMcpServer({
|
|
|
612
912
|
for (const j of finished) j.told = true
|
|
613
913
|
// The state and what finished, not the whole status: an agent
|
|
614
914
|
// waits for idle many times in a run.
|
|
615
|
-
|
|
915
|
+
// Not idle: what the page is at, as it says it — a build's step
|
|
916
|
+
// in play and how long it has run.
|
|
917
|
+
const at = idle || !pageBusy ? {} : { ...(pageBusy.busy ? { busy: pageBusy.busy } : {}), ...(pageBusy.running ? { running: pageBusy.running, ...(pageBusy.runningMs != null ? { runningS: Math.round(pageBusy.runningMs / 1000) } : {}) } : {}) }
|
|
918
|
+
return text({ connected: link.connected, state: idle ? 'idle' : 'running', ...at, finished: finished.map(finishedOut), ...(hiddenNow() ? { hidden: true } : {}), ...staleTools() })
|
|
616
919
|
}
|
|
617
920
|
return text(status())
|
|
618
921
|
},
|
|
@@ -738,7 +1041,7 @@ export function createMcpServer({
|
|
|
738
1041
|
// a picture — a batch gives a picture as its text and a file, so those are called
|
|
739
1042
|
// directly; scanruler_tools reads commands() when it is called.
|
|
740
1043
|
const shown = (c) => !compact || Boolean(c.returnsImage)
|
|
741
|
-
const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir }))]
|
|
1044
|
+
const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir, cwd }))]
|
|
742
1045
|
const listSource = () =>
|
|
743
1046
|
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
1047
|
|
|
@@ -802,14 +1105,18 @@ export function createMcpServer({
|
|
|
802
1105
|
}
|
|
803
1106
|
for (const event of ['connected', 'commands']) {
|
|
804
1107
|
link.on(event, () => {
|
|
1108
|
+
seePage(event === 'connected')
|
|
805
1109
|
remember()
|
|
806
1110
|
relist()
|
|
807
1111
|
})
|
|
808
1112
|
}
|
|
1113
|
+
seePage(true)
|
|
809
1114
|
|
|
810
1115
|
/** One of the page's tools run on the page: the result as MCP content,
|
|
811
1116
|
* or a failure. `batched`: a picture is written to a file — the call's
|
|
812
|
-
* path or a new one in
|
|
1117
|
+
* path, or a new one in the scan's folder (scanFolderNow), else in
|
|
1118
|
+
* outDir — and
|
|
1119
|
+
* given as its text, not shown. `job`:
|
|
813
1120
|
* the job the call is part of, which the page keeps its outcome under.
|
|
814
1121
|
* `deferred`: more changes follow straight after it (a batch with
|
|
815
1122
|
* build: "end"), and the page may leave their rebuild to the last.
|
|
@@ -823,18 +1130,28 @@ export function createMcpServer({
|
|
|
823
1130
|
return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
|
|
824
1131
|
}
|
|
825
1132
|
try {
|
|
826
|
-
|
|
827
|
-
|
|
1133
|
+
// Every relative path in the call from one folder, taken before a
|
|
1134
|
+
// scan opened by it moves it.
|
|
1135
|
+
const base = pathBase()
|
|
1136
|
+
const args = await withFiles(given ?? {}, base)
|
|
1137
|
+
const { input, binaryKey, file } = await inputFor(command, args, { cwd: base })
|
|
828
1138
|
// 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
|
|
1139
|
+
const savedTo = replacesSession(command) && args?.saveTo !== undefined ? await saveSession(String(args.saveTo), job, base) : undefined
|
|
830
1140
|
if (savedTo !== undefined) input.discard = true
|
|
831
1141
|
const started = Date.now()
|
|
832
1142
|
let result = await link.run(command.name, input, binaryKey, onProgress, job, deferred)
|
|
833
1143
|
// The folder a scan or a project was opened from, for scanruler_write.
|
|
834
|
-
if (binaryKey && replacesSession(command) && file)
|
|
1144
|
+
if (binaryKey && replacesSession(command) && file) {
|
|
1145
|
+
rememberFolder(file)
|
|
1146
|
+
rememberProject(file)
|
|
1147
|
+
}
|
|
1148
|
+
// And the scan open now: this one's, or — replaced without a file —
|
|
1149
|
+
// none known.
|
|
1150
|
+
if (replacesSession(command)) openedFrom(binaryKey ? file : null, result)
|
|
1151
|
+
if (!command.readOnly && pageSeen) pageSeen.held = true
|
|
835
1152
|
if (savedTo !== undefined && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, savedTo }
|
|
836
1153
|
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
|
|
1154
|
+
const image = command.returnsImage && !batched ? await imageContent(result, args, { cwd: base, outDir }) : null
|
|
838
1155
|
if (image) return { content: image }
|
|
839
1156
|
// Which steps a change's build played, and how long each took, only
|
|
840
1157
|
// for a batch asked for its timings.
|
|
@@ -842,9 +1159,13 @@ export function createMcpServer({
|
|
|
842
1159
|
const { played: _played, ...steps } = result.steps
|
|
843
1160
|
result = { ...result, steps }
|
|
844
1161
|
}
|
|
845
|
-
const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd, outDir }) : await saveFileResult(result, args, { cwd, outDir })
|
|
846
|
-
|
|
1162
|
+
const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd: base, outDir, folder: args?.path ? null : await scanFolderNow() }) : await saveFileResult(result, args, { cwd: base, outDir })
|
|
1163
|
+
// A project saved to a file: the one a reloaded page is pointed to.
|
|
1164
|
+
if (saved?.file?.path) rememberProject(saved.file.path)
|
|
1165
|
+
return text(await fitted(saved, name, Boolean(command.exact), [], { cap: capOf(command), ...(lessOf(command) ? { less: lessOf(command) } : {}) }), Boolean(command.exact))
|
|
847
1166
|
} catch (e) {
|
|
1167
|
+
// A page older than its app, said once so: the status keeps it.
|
|
1168
|
+
if (e?.code === 'stale_page' && pageSeen) pageSeen.stale ??= typeof e.message === 'string' ? e.message : 'stale_page'
|
|
848
1169
|
return failure(e)
|
|
849
1170
|
}
|
|
850
1171
|
}
|
|
@@ -852,7 +1173,7 @@ export function createMcpServer({
|
|
|
852
1173
|
/** The session saved as a project file at `path` (a file, or a folder
|
|
853
1174
|
* for its own name): where it went, or null when the session held
|
|
854
1175
|
* nothing to save. Throws, saving nothing, when the save fails. */
|
|
855
|
-
const saveSession = async (path, job) => {
|
|
1176
|
+
const saveSession = async (path, job, base = pathBase()) => {
|
|
856
1177
|
let result
|
|
857
1178
|
try {
|
|
858
1179
|
result = await link.run('project.save', {}, undefined, undefined, job)
|
|
@@ -860,22 +1181,24 @@ export function createMcpServer({
|
|
|
860
1181
|
if (/nothing to save/i.test(e?.message ?? '')) return null
|
|
861
1182
|
throw { code: e?.code ?? 'failed', message: `Nothing was replaced: the session could not be saved first — ${e?.message ?? String(e)}` }
|
|
862
1183
|
}
|
|
863
|
-
const written = await saveFileResult(result, { path }, { cwd, outDir })
|
|
1184
|
+
const written = await saveFileResult(result, { path }, { cwd: base, outDir })
|
|
864
1185
|
if (!written?.file?.path) throw { code: 'failed', message: 'Nothing was replaced: project.save gave no file to save.' }
|
|
865
1186
|
rememberFolder(written.file.path)
|
|
1187
|
+
rememberProject(written.file.path)
|
|
866
1188
|
return written.file.path
|
|
867
1189
|
}
|
|
868
1190
|
|
|
869
1191
|
// Several of the page's tools in one call — what cuts a run of twenty
|
|
870
1192
|
// scan_nearest calls to one round trip. Each answer is the tool's own; a
|
|
871
1193
|
// 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
|
|
1194
|
+
// written to a file — the call's path, or a new one in the folder the
|
|
1195
|
+
// scan open now came from (scanFolderNow), else in outDir — which the
|
|
873
1196
|
// text names.
|
|
874
1197
|
own.push({
|
|
875
1198
|
name: 'scanruler_batch',
|
|
876
1199
|
title: 'Run several ScanRuler tools',
|
|
877
1200
|
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.`,
|
|
1201
|
+
`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. notes, when a call answered with the notes left for you, is the last call’s, repeated at the top. 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, callsUndone counts the calls, and each of those answers ok: false and undone: true, its result kept as it was. Before anything runs, the names the calls would give new things (a command that says which of its fields name them, in one name space) are checked against each other: two alike — case and spaces at the ends aside — refuse the batch with nothing run, naming both calls; a name the session already has is the page’s to refuse. 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. ${relativeFrom(cwd)} 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 the folder the scan open now was opened from (by scan_open or project_load with a path), else 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
1202
|
inputSchema: {
|
|
880
1203
|
type: 'object',
|
|
881
1204
|
properties: {
|
|
@@ -894,7 +1217,7 @@ export function createMcpServer({
|
|
|
894
1217
|
additionalProperties: false,
|
|
895
1218
|
},
|
|
896
1219
|
},
|
|
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.
|
|
1220
|
+
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. ${relativeFrom(cwd)}` },
|
|
898
1221
|
stopOnError: { type: 'boolean', description: 'Stop at the first refused call (the default), or run every call and report each.' },
|
|
899
1222
|
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
1223
|
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.' },
|
|
@@ -905,7 +1228,9 @@ export function createMcpServer({
|
|
|
905
1228
|
},
|
|
906
1229
|
annotations: { readOnlyHint: false, openWorldHint: false },
|
|
907
1230
|
call: async (args, ctx) => {
|
|
908
|
-
|
|
1231
|
+
const given = callsGiven(args?.calls)
|
|
1232
|
+
if (given.error) return failure({ code: 'invalid_input', message: given.error })
|
|
1233
|
+
let calls = given.calls
|
|
909
1234
|
if (args?.file !== undefined) {
|
|
910
1235
|
if (calls.length > 0) return failure({ code: 'invalid_input', message: 'Give calls or file, not both.' })
|
|
911
1236
|
const read = await callsFromFile(String(args.file))
|
|
@@ -914,6 +1239,10 @@ export function createMcpServer({
|
|
|
914
1239
|
}
|
|
915
1240
|
if (calls.length === 0) return failure({ code: 'invalid_input', message: 'calls: give at least one call (or a file of them).' })
|
|
916
1241
|
if (ctx) ctx.fields.calls = calls.length
|
|
1242
|
+
// Two calls naming new things alike would roll the batch back at the
|
|
1243
|
+
// second, over every good change before it: refused before any runs.
|
|
1244
|
+
const clash = nameClash(calls, (tool) => commands().find((c) => toolName(c) === tool))
|
|
1245
|
+
if (clash) return failure({ code: 'invalid_input', message: clash })
|
|
917
1246
|
const job = startJob('scanruler_batch', (id) => runBatch(calls, args, ctx, id), ctx, args?.wait === false)
|
|
918
1247
|
return args?.wait === false ? queuedReply(job) : answerFor(job, ctx)
|
|
919
1248
|
},
|
|
@@ -923,7 +1252,7 @@ export function createMcpServer({
|
|
|
923
1252
|
* batch takes them, or { name, input } by the command's own name, a
|
|
924
1253
|
* list or one under "calls" or "steps". */
|
|
925
1254
|
const callsFromFile = async (file) => {
|
|
926
|
-
const path = absolute(file,
|
|
1255
|
+
const path = absolute(file, pathBase())
|
|
927
1256
|
let data
|
|
928
1257
|
try {
|
|
929
1258
|
data = JSON.parse(await readFile(path, 'utf8'))
|
|
@@ -932,7 +1261,9 @@ export function createMcpServer({
|
|
|
932
1261
|
}
|
|
933
1262
|
const list = Array.isArray(data) ? data : Array.isArray(data?.calls) ? data.calls : Array.isArray(data?.steps) ? data.steps : null
|
|
934
1263
|
if (!list) return { error: `file: ${path} holds no list of calls — a list, or one under "calls" or "steps".` }
|
|
935
|
-
const
|
|
1264
|
+
const given = callsGiven(list.map((c) => (c && typeof c === 'object' && typeof c.name === 'string' && c.tool === undefined ? { tool: toolName({ name: c.name }), arguments: c.input ?? {} } : c)))
|
|
1265
|
+
if (given.error) return { error: `file: ${path}: ${given.error}` }
|
|
1266
|
+
const { calls } = given
|
|
936
1267
|
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
1268
|
return { calls }
|
|
938
1269
|
}
|
|
@@ -977,7 +1308,7 @@ export function createMcpServer({
|
|
|
977
1308
|
const hiddenAt = hiddenNow()
|
|
978
1309
|
const deferred = atEnd && i < calls.length - 1
|
|
979
1310
|
lastDeferred = deferred
|
|
980
|
-
const run = () => runPageTool(tool, callArgs,
|
|
1311
|
+
const run = () => runPageTool(tool, callArgs, (line) => noteProgress(jobs.get(job), line, i), { timings, batched: true, job, deferred })
|
|
981
1312
|
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
1313
|
// Busy with something of the page's own — a map being worked out,
|
|
983
1314
|
// say: once it is idle, the call is run again, once.
|
|
@@ -1026,22 +1357,40 @@ export function createMcpServer({
|
|
|
1026
1357
|
rollbackError = `${e?.code ?? 'failed'}: ${e?.message ?? String(e)}`
|
|
1027
1358
|
}
|
|
1028
1359
|
}
|
|
1360
|
+
// Each change taken back says so: its result is what it did before the
|
|
1361
|
+
// rollback, which no longer stands.
|
|
1362
|
+
let callsUndone = 0
|
|
1363
|
+
if (rollback && undone !== undefined) {
|
|
1364
|
+
for (let i = 0; i < results.length - 1; i++) {
|
|
1365
|
+
const { tool, ok, ...rest } = results[i]
|
|
1366
|
+
if (!ok || !changes(tool)) continue
|
|
1367
|
+
results[i] = { tool, ok: false, undone: true, ...rest }
|
|
1368
|
+
callsUndone++
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1029
1371
|
// Stopped before its last call, a batch built at the end leaves no
|
|
1030
1372
|
// rebuild held: the page, read for idle, builds what is.
|
|
1031
1373
|
if (atEnd && lastDeferred && !rollback) await pageIdle(Date.now() + IDLE_WAIT_MS)
|
|
1032
1374
|
const kept = failed && checkpoint && !rollback ? { kept: `${failed.tool} changes nothing, so its refusal undid nothing: what the batch changed before it stands.` } : {}
|
|
1375
|
+
// The notes for the agent as the last call that gave them said — its
|
|
1376
|
+
// result spilled to a file, from its brief — at the top: not left in
|
|
1377
|
+
// the middle of a long batch, and kept in the batch's own brief.
|
|
1378
|
+
const notes = results.reduce((last, r) => notesIn(r.result) ?? notesIn(r.result?.brief) ?? last, undefined)
|
|
1033
1379
|
const answer = {
|
|
1034
1380
|
results,
|
|
1035
1381
|
ran: results.length,
|
|
1036
1382
|
of: calls.length,
|
|
1037
1383
|
...(stopped !== undefined ? { stopped } : {}),
|
|
1038
|
-
...(failed ? { failed, ...(rollback ? { undone: undone ?? [] } : {}), ...kept, ...(rollbackError ? { rollbackError } : {}) } : {}),
|
|
1384
|
+
...(failed ? { failed, ...(rollback ? { undone: undone ?? [], callsUndone } : {}), ...kept, ...(rollbackError ? { rollbackError } : {}) } : {}),
|
|
1385
|
+
...(notes !== undefined ? { notes } : {}),
|
|
1039
1386
|
...(timings ? { total_ms: Date.now() - started } : {}),
|
|
1040
1387
|
}
|
|
1041
1388
|
// Each call's result is capped already; many together may still be
|
|
1042
1389
|
// past it — then the whole goes to a file, and the answer keeps how
|
|
1043
|
-
// the batch ended.
|
|
1044
|
-
|
|
1390
|
+
// the batch ended. A call that may answer longer (a guide) lets the
|
|
1391
|
+
// batch it is in answer as long.
|
|
1392
|
+
const cap = Math.max(...calls.map((c) => capOf(commandOf(String(c?.tool ?? '')))))
|
|
1393
|
+
return text(await fitted(answer, 'scanruler_batch', exact, ['failed', 'undone'], { cap }), exact)
|
|
1045
1394
|
}
|
|
1046
1395
|
|
|
1047
1396
|
// The page's tools for a client that does not list them: a line each,
|
|
@@ -1083,7 +1432,7 @@ export function createMcpServer({
|
|
|
1083
1432
|
for (const name of names) {
|
|
1084
1433
|
const command = list.find((c) => toolName(c) === name)
|
|
1085
1434
|
const mine = own.find((t) => t.name === name)
|
|
1086
|
-
if (command) found.push(toolFor(command, { outDir }))
|
|
1435
|
+
if (command) found.push(toolFor(command, { outDir, cwd }))
|
|
1087
1436
|
else if (mine) found.push(definition(mine))
|
|
1088
1437
|
else unknown.push(name)
|
|
1089
1438
|
}
|
|
@@ -1099,13 +1448,13 @@ export function createMcpServer({
|
|
|
1099
1448
|
// lines of their own.
|
|
1100
1449
|
let callNo = 0
|
|
1101
1450
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
1102
|
-
if (!trace) return dispatch(request, extra, null)
|
|
1451
|
+
if (!trace) return withReload(await dispatch(request, extra, null))
|
|
1103
1452
|
const { name, arguments: args } = request.params
|
|
1104
1453
|
const ctx = { call: ++callNo, fields: {} }
|
|
1105
1454
|
const t = trace.now()
|
|
1106
1455
|
const hiddenAt = hiddenNow()
|
|
1107
1456
|
const running = runningJobs()
|
|
1108
|
-
const reply = await dispatch(request, extra, ctx)
|
|
1457
|
+
const reply = withReload(await dispatch(request, extra, ctx))
|
|
1109
1458
|
trace.write({
|
|
1110
1459
|
ev: 'call',
|
|
1111
1460
|
call: ctx.call,
|
|
@@ -1127,15 +1476,16 @@ export function createMcpServer({
|
|
|
1127
1476
|
const { name, arguments: args } = request.params
|
|
1128
1477
|
const mine = own.find((t) => t.name === name)
|
|
1129
1478
|
if (mine) return mine.call(args, ctx)
|
|
1130
|
-
// Progress lines from the page
|
|
1479
|
+
// Progress lines from the page: kept on the call's job, for the status
|
|
1480
|
+
// and scanruler_wait to answer, and sent on to a client that asked for
|
|
1481
|
+
// them.
|
|
1131
1482
|
const token = request.params._meta?.progressToken
|
|
1132
1483
|
let step = 0
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1484
|
+
let job = null
|
|
1485
|
+
const onProgress = (line) => {
|
|
1486
|
+
noteProgress(job, line)
|
|
1487
|
+
if (token !== undefined) extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
|
|
1488
|
+
}
|
|
1139
1489
|
// A change runs as a job, and so does a long read: answered when done —
|
|
1140
1490
|
// or with the job, at once for wait: false and at the answer limit
|
|
1141
1491
|
// otherwise — and kept either way, for a client that stops waiting
|
|
@@ -1144,7 +1494,7 @@ export function createMcpServer({
|
|
|
1144
1494
|
if (!command || (command.readOnly && !command.long)) return runPageTool(name, args, onProgress)
|
|
1145
1495
|
const { wait, timings, ...rest } = args ?? {}
|
|
1146
1496
|
const timed = timings === true && !command.readOnly
|
|
1147
|
-
|
|
1497
|
+
job = startJob(name, (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id }), ctx, wait === false)
|
|
1148
1498
|
return wait === false ? queuedReply(job) : answerFor(job, ctx)
|
|
1149
1499
|
}
|
|
1150
1500
|
|