scanruler-mcp 0.5.0 → 0.6.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/README.md +43 -17
- package/package.json +1 -1
- package/src/commands.json +347 -312
- package/src/main.js +14 -8
- package/src/mcp.js +272 -47
- package/src/tools.js +28 -9
- package/src/trace.js +12 -1
package/src/mcp.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// The MCP side: the tools an agent lists and calls. Each of the page's
|
|
3
|
-
// commands is a tool (tools.js);
|
|
3
|
+
// commands is a tool (tools.js); seven more are this server's own —
|
|
4
4
|
// scanruler_status, to see whether a ScanRuler page is connected and how to
|
|
5
5
|
// connect one; scanruler_open, to open one in the user's browser, paired;
|
|
6
6
|
// scanruler_wait, to wait for it or for it to be idle; scanruler_batch, to
|
|
7
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;
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// text file — a report — next to the scan; scanruler_files, to list what is
|
|
9
|
+
// there; and scanruler_read, to read a text file of it, an answer too long
|
|
10
|
+
// to carry among them. In compact mode, for a model with a small context
|
|
11
|
+
// window, the page's tools are not listed but for those that return a picture,
|
|
11
12
|
// which a batch cannot show — it writes the picture to a file and gives its
|
|
12
13
|
// text: one more of the server's own, scanruler_tools, lists them a line
|
|
13
14
|
// each and gives the definitions of those asked for, and scanruler_batch
|
|
14
|
-
// runs them.
|
|
15
|
-
//
|
|
15
|
+
// runs them. With a list of namespaces (--tools scan,view or
|
|
16
|
+
// -flat,-thickness), only the commands of those namespaces, or all but
|
|
17
|
+
// those, are tools, beside the server's own; the others are unknown here.
|
|
18
|
+
// Headless (engine.js), the tools are the commands ScanRuler runs in this
|
|
19
|
+
// process with no page, listed in any mode.
|
|
16
20
|
//
|
|
17
21
|
// No call is held past the answer limit (`answerWithinMs`): a client, or a
|
|
18
22
|
// bridge between it and this computer, may cut a call at a minute. A change
|
|
@@ -20,12 +24,12 @@
|
|
|
20
24
|
|
|
21
25
|
import { randomBytes } from 'node:crypto'
|
|
22
26
|
import { readFileSync } from 'node:fs'
|
|
23
|
-
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
27
|
+
import { mkdir, open, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
24
28
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
25
29
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
26
30
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
27
|
-
import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, relativeFrom, replacesSession, saveFileResult, toolFor, toolName, waitIn, withFiles, writeNew } from './tools.js'
|
|
28
|
-
import { jsonBytes, replyMeasure } from './trace.js'
|
|
31
|
+
import { absolute, agentJson, batchedPicture, fileKind, imageContent, inputFor, relativeFrom, replacesSession, saveFileResult, TEXT_FILES, toolFor, toolName, waitIn, withFiles, wholeUtf8, writeNew } from './tools.js'
|
|
32
|
+
import { jsonBytes, replyHead, replyMeasure } from './trace.js'
|
|
29
33
|
|
|
30
34
|
/** The app's own commands as of this release, so the tools are there before
|
|
31
35
|
* a page connects — unless a page has connected before whose list was kept
|
|
@@ -75,9 +79,46 @@ export function listBeforePage(kept, snapshot = SNAPSHOT, of = SNAPSHOT_OF) {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
/** How the page's commands are listed: `full`, each its own tool, or
|
|
78
|
-
* `compact`, read through scanruler_tools.
|
|
82
|
+
* `compact`, read through scanruler_tools. A third way is a list of
|
|
83
|
+
* namespaces (parseToolMode). */
|
|
79
84
|
export const TOOL_MODES = ['full', 'compact']
|
|
80
85
|
|
|
86
|
+
/** The namespace of a command's tool: the part of its name before the
|
|
87
|
+
* first underscore — scan for scan_open, view for view_render. */
|
|
88
|
+
const namespaceOf = (command) => toolName(command).split('_')[0]
|
|
89
|
+
|
|
90
|
+
/** How `--tools` reads. `full` and `compact` as they are; otherwise a
|
|
91
|
+
* comma-separated list of namespaces, the part of a tool's name before
|
|
92
|
+
* its first underscore: plain (`scan,view`) lists those namespaces alone,
|
|
93
|
+
* each with a leading `-` (`-flat,-thickness`) lists every namespace but
|
|
94
|
+
* those — and the server's own tools either way, each listed command a
|
|
95
|
+
* tool of its own as in full. A list that mixes the two forms, or is
|
|
96
|
+
* empty, throws, its message starting with `flag` (`--tools` or
|
|
97
|
+
* `SCANRULER_MCP_TOOLS`). A namespace no page has is not refused: a page
|
|
98
|
+
* may add it; the server says at a page's connection which entries match
|
|
99
|
+
* none of its namespaces. Returns { text, compact, named, lists }: `text`
|
|
100
|
+
* is the mode as given, what the status and serverInfo report; `named`
|
|
101
|
+
* the namespaces in the list (null for full and compact); `lists(ns)`
|
|
102
|
+
* whether a command of that namespace is listed. */
|
|
103
|
+
export function parseToolMode(given, flag = '--tools') {
|
|
104
|
+
const text = String(given ?? '').trim()
|
|
105
|
+
if (TOOL_MODES.includes(text)) return { text, compact: text === 'compact', named: null, lists: () => true }
|
|
106
|
+
const forms = `${TOOL_MODES.join(', ')}, namespaces to list (scan,view) or namespaces to leave out (-flat,-thickness)`
|
|
107
|
+
if (text === '') throw new Error(`${flag} is empty: give ${forms}.`)
|
|
108
|
+
const entries = text.split(',').map((s) => s.trim())
|
|
109
|
+
const bad = entries.find((e) => !/^-?[A-Za-z0-9]+$/.test(e))
|
|
110
|
+
if (bad !== undefined) {
|
|
111
|
+
throw new Error(`${flag} ${text}: "${bad}" is no namespace — a namespace is the part of a tool's name before its first underscore (scan, view, element …), with a leading - to leave it out. Give ${forms}.`)
|
|
112
|
+
}
|
|
113
|
+
const out = entries.filter((e) => e.startsWith('-'))
|
|
114
|
+
if (out.length > 0 && out.length < entries.length) {
|
|
115
|
+
throw new Error(`${flag} ${text} mixes namespaces to list (${entries.filter((e) => !e.startsWith('-')).join(', ')}) with namespaces to leave out (${out.join(', ')}): give one form — scan,view lists those alone, -flat,-thickness lists all but those.`)
|
|
116
|
+
}
|
|
117
|
+
const named = new Set(entries.map((e) => e.replace(/^-/, '')))
|
|
118
|
+
const exclude = out.length > 0
|
|
119
|
+
return { text, compact: false, named, lists: (ns) => named.has(ns) !== exclude }
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
/** The most definitions scanruler_tools gives in one answer: twenty are
|
|
82
123
|
* some ten thousand tokens, a small window's fair share. */
|
|
83
124
|
const MAX_DEFINITIONS = 20
|
|
@@ -294,6 +335,11 @@ const FOLDERS_KEPT = 10
|
|
|
294
335
|
/** How many entries scanruler_files lists at most. */
|
|
295
336
|
const FILES_LISTED = 300
|
|
296
337
|
|
|
338
|
+
/** The folder in the scan's folder that the server's own files go into
|
|
339
|
+
* when no path was given — answers too long to carry, a batch's pictures
|
|
340
|
+
* — kept apart from the person's files beside the scan. */
|
|
341
|
+
const WORK_FOLDER = 'scanruler-work'
|
|
342
|
+
|
|
297
343
|
const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)))
|
|
298
344
|
|
|
299
345
|
/** The notes an answer carries for the agent (`notes`), when it is an
|
|
@@ -328,13 +374,16 @@ const failure = (e) => {
|
|
|
328
374
|
* openPage?: () => Promise<boolean>,
|
|
329
375
|
* openGraceMs?: number,
|
|
330
376
|
* openWaitMs?: number,
|
|
331
|
-
* tools?:
|
|
377
|
+
* tools?: string,
|
|
378
|
+
* log?: (line: string) => void,
|
|
332
379
|
* trace?: ReturnType<typeof import('./trace.js').createTrace>,
|
|
333
380
|
* answerWithinMs?: number,
|
|
334
381
|
* folderStore?: { load(): string[] | null, save(folders: string[]): void } | null,
|
|
335
382
|
* listWaitMs?: number,
|
|
336
383
|
* answerCap?: number,
|
|
337
|
-
* }} options — `tools` is how the page's commands are listed
|
|
384
|
+
* }} options — `tools` is how the page's commands are listed: full,
|
|
385
|
+
* compact, or a list of namespaces (parseToolMode). `log` takes the
|
|
386
|
+
* server's lines for the user — a namespace listed that no page has.
|
|
338
387
|
* `listWaitMs`: how long a listing waits for a page that connected before
|
|
339
388
|
* and is not back yet. `answerCap`: the most characters an answer
|
|
340
389
|
* carries; a longer one goes to a file in the folder the scan open now
|
|
@@ -364,6 +413,7 @@ export function createMcpServer({
|
|
|
364
413
|
openGraceMs = 11_000,
|
|
365
414
|
openWaitMs = 40_000,
|
|
366
415
|
tools: toolMode = 'full',
|
|
416
|
+
log = (line) => process.stderr.write(`[scanruler-mcp] ${line}\n`),
|
|
367
417
|
trace = null,
|
|
368
418
|
answerWithinMs = ANSWER_WITHIN_MS,
|
|
369
419
|
folderStore = null,
|
|
@@ -374,10 +424,19 @@ export function createMcpServer({
|
|
|
374
424
|
const holdMs = answerWithinMs > 0 ? answerWithinMs : Infinity
|
|
375
425
|
const holdS = answerWithinMs > 0 ? answerWithinMs / 1000 : null
|
|
376
426
|
const headless = link.backend === 'headless'
|
|
377
|
-
|
|
427
|
+
// Full, compact, or the namespaces to list: a command of a namespace
|
|
428
|
+
// left out is not listed, and not run either.
|
|
429
|
+
const selection = parseToolMode(toolMode)
|
|
430
|
+
const compact = selection.compact
|
|
431
|
+
const leftOut = (c) => !compact && !selection.lists(namespaceOf(c))
|
|
378
432
|
const server = new Server(
|
|
379
433
|
{ name: 'scanruler', title: 'ScanRuler', version },
|
|
380
|
-
{
|
|
434
|
+
{
|
|
435
|
+
capabilities: { tools: { listChanged: true } },
|
|
436
|
+
instructions:
|
|
437
|
+
(headless ? HEADLESS_INSTRUCTIONS : INSTRUCTIONS)[compact ? 'compact' : 'full'] +
|
|
438
|
+
(selection.named ? ` This server lists ScanRuler’s tools by namespace, as it was started (--tools ${selection.text}); a tool of a namespace left out is unknown here.` : ''),
|
|
439
|
+
},
|
|
381
440
|
)
|
|
382
441
|
|
|
383
442
|
// The last connected page's commands, for listing before a page is back:
|
|
@@ -422,7 +481,7 @@ export function createMcpServer({
|
|
|
422
481
|
job.reply = reply
|
|
423
482
|
// Answered to the client: seen, unless queued or likely timed out.
|
|
424
483
|
job.told = !job.queued && job.ms < SEEN_WITHIN_MS
|
|
425
|
-
if (job.queued && trace) trace.write({ ev: 'job', job: job.job, call: ctx?.call ?? null, tool, t: job.started, ms: job.ms, ...replyMeasure(reply) })
|
|
484
|
+
if (job.queued && trace) trace.write({ ev: 'job', job: job.job, call: ctx?.call ?? null, tool, t: job.started, ms: job.ms, ...replyMeasure(reply), ...(trace.args ? { head: replyHead(reply) } : {}) })
|
|
426
485
|
return reply
|
|
427
486
|
}
|
|
428
487
|
job.done = Promise.resolve()
|
|
@@ -491,7 +550,7 @@ export function createMcpServer({
|
|
|
491
550
|
job: job.job,
|
|
492
551
|
tool: job.tool,
|
|
493
552
|
after: holdS,
|
|
494
|
-
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.`,
|
|
553
|
+
note: `Still running after ${holdS} s, and running on${hiddenNow() ? '; the ScanRuler tab is hidden' : ''}. scanruler_wait { for: "job", job: "${job.job}" } gives its answer — call it again while it says running.`,
|
|
495
554
|
})
|
|
496
555
|
}
|
|
497
556
|
|
|
@@ -541,7 +600,7 @@ export function createMcpServer({
|
|
|
541
600
|
...(o.resultOmitted ? { resultOmitted: o.resultOmitted } : {}),
|
|
542
601
|
...(o.error ? { error: o.error } : {}),
|
|
543
602
|
}))
|
|
544
|
-
const from = `the page (session.recent): this server copy, started at ${startedAt}, did not start ${job}; the page kept what was run under it.`
|
|
603
|
+
const from = `the page (session.recent): this server copy, started at ${startedAt}, did not start ${job}; the page kept what was run under it, but no picture or file it gave.`
|
|
545
604
|
if (calls.length === 1) return { job, ...calls[0], from }
|
|
546
605
|
return {
|
|
547
606
|
job,
|
|
@@ -555,6 +614,9 @@ export function createMcpServer({
|
|
|
555
614
|
/** Whether the tab is hidden now — null with no page, or headless. */
|
|
556
615
|
const hiddenNow = () => (link.connected && link.page && !headless ? (link.page.hidden ?? false) : null)
|
|
557
616
|
const runningJobs = () => [...jobs.values()].filter((j) => j.state === 'running').length
|
|
617
|
+
/** The page's reads being answered now, which no job stands for: an export
|
|
618
|
+
* writing its file, a check — what a wait for idle has to count too. */
|
|
619
|
+
const callsInFlight = new Set()
|
|
558
620
|
|
|
559
621
|
/** A finished job as scanruler_wait { for: "idle" } lists it: a failed
|
|
560
622
|
* one with its error, a batch with the call it stopped at. */
|
|
@@ -609,6 +671,14 @@ export function createMcpServer({
|
|
|
609
671
|
if (state && kept.scan !== null && (state.scan?.fileName ?? null) !== kept.scan) return null
|
|
610
672
|
return kept.folder
|
|
611
673
|
}
|
|
674
|
+
/** The folder in the scan's folder the server's own files go into — an
|
|
675
|
+
* answer too long to carry, a batch's picture — so they do not lie
|
|
676
|
+
* among the person's: WORK_FOLDER, or null as scanFolderNow. Still in
|
|
677
|
+
* the scan's folder, which a sandboxed agent can open. */
|
|
678
|
+
const workFolderNow = async () => {
|
|
679
|
+
const folder = await scanFolderNow()
|
|
680
|
+
return folder ? join(folder, WORK_FOLDER) : null
|
|
681
|
+
}
|
|
612
682
|
|
|
613
683
|
// ---- the page that came back ----------------------------------------------
|
|
614
684
|
// A page names itself — pageId, new each time it loads — and says when it
|
|
@@ -701,9 +771,12 @@ export function createMcpServer({
|
|
|
701
771
|
// An answer past answerCap — a read with every point in it, a batch of
|
|
702
772
|
// many — would crowd out the agent's context: it is written whole to a
|
|
703
773
|
// 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
|
|
705
|
-
// sandboxed agent can open that one, not the server's
|
|
706
|
-
// when that folder takes no file, into outDir.
|
|
774
|
+
// goes into WORK_FOLDER in the folder the scan open now came from
|
|
775
|
+
// (workFolderNow) — a sandboxed agent can open that one, not the server's
|
|
776
|
+
// outDir — else, or when that folder takes no file, into outDir.
|
|
777
|
+
/** The answers this copy wrote to a file: scanruler_read reads them
|
|
778
|
+
* wherever they lie, outDir being no folder of a scan's. */
|
|
779
|
+
const spilled = new Set()
|
|
707
780
|
/** The most characters a command's answer carries (ANSWER_CAPS), never
|
|
708
781
|
* under answerCap. */
|
|
709
782
|
const capOf = (command) => Math.max(answerCap, ANSWER_CAPS[command?.name] ?? 0)
|
|
@@ -718,16 +791,17 @@ export function createMcpServer({
|
|
|
718
791
|
if (body.length <= cap) return value
|
|
719
792
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
720
793
|
const name = `${tool}-${stamp}.json`
|
|
721
|
-
const
|
|
794
|
+
const workFolder = await workFolderNow()
|
|
722
795
|
let file
|
|
723
796
|
let where
|
|
724
797
|
try {
|
|
725
|
-
file =
|
|
726
|
-
where = file ? `It is in ${
|
|
798
|
+
file = workFolder ? await writeNew(workFolder, name, body).catch(() => null) : null
|
|
799
|
+
where = file ? `It is in ${workFolder}, ${WORK_FOLDER} in the folder the scan open now was opened from.` : `It is in ${outDir}, the server's output folder${workFolder ? ` — ${workFolder}, in the scan's folder, took no file` : ': the scan open now was not opened from a folder through this server'}.`
|
|
727
800
|
file ??= await writeNew(outDir, name, body)
|
|
728
801
|
} catch {
|
|
729
802
|
return value
|
|
730
803
|
}
|
|
804
|
+
spilled.add(resolve(file))
|
|
731
805
|
const object = value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
732
806
|
const short = (k, v) => keep.includes(k) || k === 'notes' || v === null || typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 200)
|
|
733
807
|
const brief = object ? Object.fromEntries(Object.entries(value).filter(([k, v]) => short(k, v))) : {}
|
|
@@ -742,15 +816,16 @@ export function createMcpServer({
|
|
|
742
816
|
bytes: Buffer.byteLength(body, 'utf8'),
|
|
743
817
|
...(object ? { keys: Object.keys(value) } : Array.isArray(value) ? { items: value.length } : {}),
|
|
744
818
|
...(Object.keys(brief).length ? { brief } : {}),
|
|
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}`,
|
|
819
|
+
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${headless ? '' : ' — scanruler_read { path: file } gives it in parts, where you cannot open the folder —'} or ask for less next time (${less}). ${where}`,
|
|
746
820
|
}
|
|
747
821
|
}
|
|
748
822
|
|
|
749
823
|
// ---- folders -------------------------------------------------------------
|
|
750
824
|
// The folders this server opened a scan or a project from, or saved one
|
|
751
|
-
// into, the latest first: where scanruler_write may write and what
|
|
752
|
-
// scanruler_files lists. Kept beside the command
|
|
753
|
-
// copy started afresh — or the other copy beside
|
|
825
|
+
// into, the latest first: where scanruler_write may write, and what
|
|
826
|
+
// scanruler_files lists and scanruler_read reads. Kept beside the command
|
|
827
|
+
// list (folderStore), so a copy started afresh — or the other copy beside
|
|
828
|
+
// this one — has them too.
|
|
754
829
|
let folders = []
|
|
755
830
|
const knownFolders = () => {
|
|
756
831
|
try {
|
|
@@ -839,7 +914,7 @@ export function createMcpServer({
|
|
|
839
914
|
{
|
|
840
915
|
name: 'scanruler_status',
|
|
841
916
|
title: 'ScanRuler connection status',
|
|
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
|
|
917
|
+
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, scanruler_files lists and scanruler_read reads; 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 without a path, go into scanruler-work in the folder the scan open now was opened from, when it was opened here by a path — the server’s own files, kept apart from the person’s (scanruler_files { path: "scanruler-work" } lists them, scanruler_read reads them). 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.',
|
|
843
918
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
844
919
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
845
920
|
call: async () => {
|
|
@@ -851,7 +926,7 @@ export function createMcpServer({
|
|
|
851
926
|
{
|
|
852
927
|
name: 'scanruler_open',
|
|
853
928
|
title: 'Open ScanRuler',
|
|
854
|
-
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
|
|
929
|
+
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, ScanRuler warns the user that what you read goes to you and your model, and connects only once they accept; then the browser asks whether the site may reach apps on this computer, and 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.',
|
|
855
930
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
856
931
|
annotations: { readOnlyHint: false, openWorldHint: false },
|
|
857
932
|
call: async () => {
|
|
@@ -867,7 +942,7 @@ export function createMcpServer({
|
|
|
867
942
|
{
|
|
868
943
|
name: 'scanruler_wait',
|
|
869
944
|
title: 'Wait for ScanRuler',
|
|
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.`,
|
|
945
|
+
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 picture the job's tool gave comes with that answer, as an image. A job of an earlier copy of this server is read from the page's record of it (session.recent), from says so; that keeps no picture.${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.`,
|
|
871
946
|
inputSchema: {
|
|
872
947
|
type: 'object',
|
|
873
948
|
properties: {
|
|
@@ -894,18 +969,20 @@ export function createMcpServer({
|
|
|
894
969
|
await Promise.race([j.done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
|
|
895
970
|
clearTimeout(timer)
|
|
896
971
|
if (j.state !== 'running') j.told = true
|
|
897
|
-
|
|
972
|
+
// The picture the job's tool gave, as the call itself would show it.
|
|
973
|
+
const answer = text(await fitted(jobAnswer(j), 'scanruler_wait'))
|
|
974
|
+
return { ...answer, content: [...answer.content, ...(j.reply?.content ?? []).filter((x) => x.type === 'image')] }
|
|
898
975
|
}
|
|
899
976
|
if (!(await link.waitForPage(Math.max(0, until - Date.now())))) return text(status())
|
|
900
977
|
if (args?.for === 'idle') {
|
|
901
978
|
// The changes still running first — their answers are what idle
|
|
902
979
|
// waits for — then the page's own work.
|
|
903
|
-
for (const
|
|
980
|
+
for (const done of [...[...jobs.values()].filter((x) => x.state === 'running').map((x) => x.done), ...callsInFlight]) {
|
|
904
981
|
let timer
|
|
905
|
-
await Promise.race([
|
|
982
|
+
await Promise.race([done, new Promise((r) => (timer = setTimeout(r, Math.max(0, until - Date.now()))))])
|
|
906
983
|
clearTimeout(timer)
|
|
907
984
|
}
|
|
908
|
-
const idle = (await pageIdle(until)) && runningJobs() === 0
|
|
985
|
+
const idle = (await pageIdle(until)) && runningJobs() === 0 && callsInFlight.size === 0
|
|
909
986
|
// What finished since it was last said: each job once, a failed
|
|
910
987
|
// one with its error.
|
|
911
988
|
const finished = [...jobs.values()].filter((j) => j.state !== 'running' && !j.told)
|
|
@@ -967,6 +1044,29 @@ export function createMcpServer({
|
|
|
967
1044
|
}
|
|
968
1045
|
},
|
|
969
1046
|
})
|
|
1047
|
+
own.push({
|
|
1048
|
+
name: 'scanruler_read',
|
|
1049
|
+
title: 'Read a text file beside the scan',
|
|
1050
|
+
description: `A text file read part by part: ${TEXT_FILES.join(', ')}, in the folder this server last opened a scan or a project from, or saved one into, in a folder under it, or in one of the folders scanruler_status lists — or an answer too long to carry (truncated: true, file), wherever it was written. A relative path is taken in the first of those folders, and a path may not climb out with "..". Returns { text, offset, nextOffset, size }, in bytes of the file: nextOffset, when there, is where to read on from; without it the text reaches the file's end. An answer holds at most ${Math.round(answerCap / 1000)} k characters. Refused for any other kind of file, and, but for such an answer, while no scan or project has been opened from a folder.`,
|
|
1051
|
+
inputSchema: {
|
|
1052
|
+
type: 'object',
|
|
1053
|
+
properties: {
|
|
1054
|
+
path: { type: 'string', minLength: 1, description: 'The file: a name in the scan’s folder, a path under it ("scanruler-work/report.json"), or an absolute path.' },
|
|
1055
|
+
offset: { type: 'integer', minimum: 0, description: 'Where to start, in bytes: the last answer’s nextOffset. The file’s start by default.' },
|
|
1056
|
+
limit: { type: 'integer', minimum: 1, description: 'The most bytes to read, a whole character at least; as many as an answer holds by default, and never more.' },
|
|
1057
|
+
},
|
|
1058
|
+
required: ['path'],
|
|
1059
|
+
additionalProperties: false,
|
|
1060
|
+
},
|
|
1061
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
1062
|
+
call: async (args) => {
|
|
1063
|
+
try {
|
|
1064
|
+
return text(await readText(args?.path, args?.offset, args?.limit))
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
return failure(e)
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
})
|
|
970
1070
|
}
|
|
971
1071
|
|
|
972
1072
|
/** scanruler_files' list — see its description. */
|
|
@@ -1007,6 +1107,76 @@ export function createMcpServer({
|
|
|
1007
1107
|
}
|
|
1008
1108
|
}
|
|
1009
1109
|
|
|
1110
|
+
/** scanruler_read's part of a text file, kept to the folders a scan or a
|
|
1111
|
+
* project came from and the answers this copy wrote to a file — see its
|
|
1112
|
+
* description. Cut on a character, and short enough that its answer, its
|
|
1113
|
+
* JSON's escapes counted, stays within answerCap. */
|
|
1114
|
+
const readText = async (path, offset = 0, limit = answerCap) => {
|
|
1115
|
+
if (typeof path !== 'string' || path.length === 0) throw { code: 'invalid_input', message: 'path: give the file to read.' }
|
|
1116
|
+
if (!Number.isInteger(offset) || offset < 0) throw { code: 'invalid_input', message: 'offset: give a whole number of bytes, 0 or more — the last answer’s nextOffset.' }
|
|
1117
|
+
if (!Number.isInteger(limit) || limit < 1) throw { code: 'invalid_input', message: 'limit: give a whole number of bytes, 1 or more.' }
|
|
1118
|
+
if (fileKind(path) !== 'text') throw { code: 'invalid_input', message: `path: only ${TEXT_FILES.join(', ')} files are read, as text — not "${extname(path).toLowerCase() || 'no extension'}".` }
|
|
1119
|
+
if (path.split(/[\\/]/).includes('..')) throw { code: 'invalid_input', message: 'path: ".." is not taken — give a path in the scan’s folder or under it.' }
|
|
1120
|
+
const folders = knownFolders()
|
|
1121
|
+
const target = resolve(folders[0] ?? outDir, path)
|
|
1122
|
+
const inside = (folder) => {
|
|
1123
|
+
const r = relative(folder, target)
|
|
1124
|
+
return r !== '' && !r.startsWith('..') && !isAbsolute(r)
|
|
1125
|
+
}
|
|
1126
|
+
if (!spilled.has(target)) {
|
|
1127
|
+
if (folders.length === 0) throw { code: 'invalid_state', message: 'This server has opened no scan or project from a folder, so it has none to read from. Open the scan with scan_open (or a project with project_load) by its path first.' }
|
|
1128
|
+
if (!folders.some(inside)) throw { code: 'invalid_input', message: `path: ${target} is outside the folders a scan or a project was opened from — ${folders.join(', ')}.` }
|
|
1129
|
+
}
|
|
1130
|
+
const most = Math.min(limit, answerCap)
|
|
1131
|
+
let size
|
|
1132
|
+
let start
|
|
1133
|
+
let bytes
|
|
1134
|
+
let handle
|
|
1135
|
+
try {
|
|
1136
|
+
handle = await open(target, 'r')
|
|
1137
|
+
;({ size } = await handle.stat())
|
|
1138
|
+
start = Math.min(offset, size)
|
|
1139
|
+
// Room to pass a character cut at the offset, and for a whole one after.
|
|
1140
|
+
const buffer = Buffer.alloc(Math.min(Math.max(4, most) + 3, size - start))
|
|
1141
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start)
|
|
1142
|
+
bytes = buffer.subarray(0, bytesRead)
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
throw { code: 'not_found', message: `${target} could not be read: ${e?.code === 'ENOENT' ? 'there is no such file' : (e?.message ?? String(e))}.` }
|
|
1145
|
+
} finally {
|
|
1146
|
+
await handle?.close()
|
|
1147
|
+
}
|
|
1148
|
+
// An offset in the middle of a character starts at the next one.
|
|
1149
|
+
for (let i = 0; i < 3 && start > 0 && (bytes[0] & 0xc0) === 0x80; i++) {
|
|
1150
|
+
bytes = bytes.subarray(1)
|
|
1151
|
+
start++
|
|
1152
|
+
}
|
|
1153
|
+
let chars
|
|
1154
|
+
try {
|
|
1155
|
+
// Nothing whole is a character the file itself ends inside: refused.
|
|
1156
|
+
const whole = wholeUtf8(bytes, most)
|
|
1157
|
+
chars = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(whole.length > 0 ? whole : bytes)
|
|
1158
|
+
} catch {
|
|
1159
|
+
throw { code: 'invalid_input', message: `path: ${target} does not read as UTF-8 text, so it may not be a text file.` }
|
|
1160
|
+
}
|
|
1161
|
+
// The most characters whose answer fits, a surrogate pair kept whole.
|
|
1162
|
+
const fits = (n) => agentJson({ text: chars.slice(0, n), offset: start, nextOffset: size, size }).length <= answerCap
|
|
1163
|
+
let count = chars.length
|
|
1164
|
+
if (!fits(count)) {
|
|
1165
|
+
let low = 0
|
|
1166
|
+
let high = count
|
|
1167
|
+
while (high - low > 1) {
|
|
1168
|
+
const mid = (low + high) >> 1
|
|
1169
|
+
if (fits(mid)) low = mid
|
|
1170
|
+
else high = mid
|
|
1171
|
+
}
|
|
1172
|
+
const last = chars.charCodeAt(low - 1)
|
|
1173
|
+
count = last >= 0xd800 && last < 0xdc00 ? low - 1 : low
|
|
1174
|
+
}
|
|
1175
|
+
const part = chars.slice(0, count)
|
|
1176
|
+
const next = start + Buffer.byteLength(part, 'utf8')
|
|
1177
|
+
return { text: part, offset: start, ...(next < size ? { nextOffset: next } : {}), size }
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1010
1180
|
/** scanruler_write's file, kept to the folders a scan or a project came
|
|
1011
1181
|
* from — see its description. */
|
|
1012
1182
|
const writeText = async (path, body) => {
|
|
@@ -1039,8 +1209,9 @@ export function createMcpServer({
|
|
|
1039
1209
|
const definition = ({ call: _call, ...tool }) => tool
|
|
1040
1210
|
// Compact, the list is the server's own tools and the page's that return
|
|
1041
1211
|
// a picture — a batch gives a picture as its text and a file, so those are called
|
|
1042
|
-
// directly; scanruler_tools reads commands() when it is called.
|
|
1043
|
-
|
|
1212
|
+
// directly; scanruler_tools reads commands() when it is called. With
|
|
1213
|
+
// namespaces, the server's own and the page's of the namespaces listed.
|
|
1214
|
+
const shown = (c) => (compact ? Boolean(c.returnsImage) : !leftOut(c))
|
|
1044
1215
|
const listNow = () => [...own.map(definition), ...commands().filter(shown).map((c) => toolFor(c, { outDir, cwd }))]
|
|
1045
1216
|
const listSource = () =>
|
|
1046
1217
|
link.page?.commands?.length ? `the page, ScanRuler ${link.page.version ?? 'of no version'}` : lastPage ? lastPage.from : `this server’s own list (scanruler-mcp ${version})`
|
|
@@ -1103,19 +1274,35 @@ export function createMcpServer({
|
|
|
1103
1274
|
},
|
|
1104
1275
|
}
|
|
1105
1276
|
}
|
|
1277
|
+
// A namespace in --tools that none of the page's commands is in — a
|
|
1278
|
+
// typo, or a plugin the page lacks — said once per page, and again when
|
|
1279
|
+
// the page's commands change it.
|
|
1280
|
+
let unmatchedSaid = null
|
|
1281
|
+
const noteNamespaces = () => {
|
|
1282
|
+
if (!selection.named || !link.page?.commands?.length) return
|
|
1283
|
+
const have = new Set(link.page.commands.map(namespaceOf))
|
|
1284
|
+
const unmatched = [...selection.named].filter((ns) => !have.has(ns))
|
|
1285
|
+
const key = unmatched.join(',')
|
|
1286
|
+
if (key === unmatchedSaid) return
|
|
1287
|
+
unmatchedSaid = key
|
|
1288
|
+
if (unmatched.length === 0) return
|
|
1289
|
+
log(`--tools ${selection.text}: no tool of the ${headless ? 'engine' : 'page'} (ScanRuler ${link.page.version ?? 'of no version'}) is in ${unmatched.join(', ')}; its namespaces are ${[...have].sort().join(', ')}.`)
|
|
1290
|
+
}
|
|
1106
1291
|
for (const event of ['connected', 'commands']) {
|
|
1107
1292
|
link.on(event, () => {
|
|
1108
1293
|
seePage(event === 'connected')
|
|
1109
1294
|
remember()
|
|
1110
1295
|
relist()
|
|
1296
|
+
noteNamespaces()
|
|
1111
1297
|
})
|
|
1112
1298
|
}
|
|
1113
1299
|
seePage(true)
|
|
1300
|
+
noteNamespaces()
|
|
1114
1301
|
|
|
1115
1302
|
/** One of the page's tools run on the page: the result as MCP content,
|
|
1116
1303
|
* or a failure. `batched`: a picture is written to a file — the call's
|
|
1117
|
-
* path, or a new one in the scan's folder (
|
|
1118
|
-
* outDir — and
|
|
1304
|
+
* path, or a new one in WORK_FOLDER in the scan's folder (workFolderNow),
|
|
1305
|
+
* else in outDir — and
|
|
1119
1306
|
* given as its text, not shown. `job`:
|
|
1120
1307
|
* the job the call is part of, which the page keeps its outcome under.
|
|
1121
1308
|
* `deferred`: more changes follow straight after it (a batch with
|
|
@@ -1126,6 +1313,9 @@ export function createMcpServer({
|
|
|
1126
1313
|
const needsPage = !command && headless ? link.pageCommand(name) : null
|
|
1127
1314
|
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.` })
|
|
1128
1315
|
if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
|
|
1316
|
+
if (leftOut(command)) {
|
|
1317
|
+
return failure({ code: 'unknown_command', message: `There is no tool ${name} here: this server was started with --tools ${selection.text}, which leaves the ${namespaceOf(command)}_ tools out. A server started with --tools full, or with a list that has ${namespaceOf(command)}, offers it.` })
|
|
1318
|
+
}
|
|
1129
1319
|
if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
|
|
1130
1320
|
return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
|
|
1131
1321
|
}
|
|
@@ -1159,9 +1349,13 @@ export function createMcpServer({
|
|
|
1159
1349
|
const { played: _played, ...steps } = result.steps
|
|
1160
1350
|
result = { ...result, steps }
|
|
1161
1351
|
}
|
|
1162
|
-
const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd: base, outDir, folder: args?.path ? null : await
|
|
1352
|
+
const saved = command.returnsImage && batched ? await batchedPicture(result, args, { cwd: base, outDir, folder: args?.path ? null : await workFolderNow() }) : await saveFileResult(result, args, { cwd: base, outDir })
|
|
1163
1353
|
// A project saved to a file: the one a reloaded page is pointed to.
|
|
1164
1354
|
if (saved?.file?.path) rememberProject(saved.file.path)
|
|
1355
|
+
if (saved?.holds && saved?.file?.path) {
|
|
1356
|
+
const lost = lostSinceLastSave(saved)
|
|
1357
|
+
if (lost) saved.warning = saved.warning ? `${saved.warning} ${lost}` : lost
|
|
1358
|
+
}
|
|
1165
1359
|
return text(await fitted(saved, name, Boolean(command.exact), [], { cap: capOf(command), ...(lessOf(command) ? { less: lessOf(command) } : {}) }), Boolean(command.exact))
|
|
1166
1360
|
} catch (e) {
|
|
1167
1361
|
// A page older than its app, said once so: the status keeps it.
|
|
@@ -1170,6 +1364,20 @@ export function createMcpServer({
|
|
|
1170
1364
|
}
|
|
1171
1365
|
}
|
|
1172
1366
|
|
|
1367
|
+
/** What the last save from this server held and this one does not — the
|
|
1368
|
+
* page was reset or reloaded between them, as its own memory cannot tell —
|
|
1369
|
+
* in a sentence; null when nothing fell away. */
|
|
1370
|
+
let lastSave = null
|
|
1371
|
+
const lostSinceLastSave = ({ holds, aligned, file }) => {
|
|
1372
|
+
const was = lastSave
|
|
1373
|
+
lastSave = { holds, aligned, path: file.path }
|
|
1374
|
+
if (!was) return null
|
|
1375
|
+
const gone = Object.entries(was.holds).filter(([what, n]) => n > 0 && !(holds[what] > 0)).map(([what, n]) => `${n} ${what}`)
|
|
1376
|
+
const frame = was.aligned && !aligned
|
|
1377
|
+
if (gone.length === 0 && !frame) return null
|
|
1378
|
+
return `This file holds less than the last save (${was.path}): ${[...gone, ...(frame ? ['the alignment'] : [])].join(', ')} gone — the page was reset or reloaded between them, so it is no copy of that work${frame ? ', and coordinates from before no longer hold on the scan' : ''}.`
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1173
1381
|
/** The session saved as a project file at `path` (a file, or a folder
|
|
1174
1382
|
* for its own name): where it went, or null when the session held
|
|
1175
1383
|
* nothing to save. Throws, saving nothing, when the save fails. */
|
|
@@ -1191,14 +1399,14 @@ export function createMcpServer({
|
|
|
1191
1399
|
// Several of the page's tools in one call — what cuts a run of twenty
|
|
1192
1400
|
// scan_nearest calls to one round trip. Each answer is the tool's own; a
|
|
1193
1401
|
// picture comes back as the text beside it, not as an image, and is
|
|
1194
|
-
// written to a file — the call's path, or a new one in
|
|
1195
|
-
// scan open now came from (
|
|
1196
|
-
// text names.
|
|
1402
|
+
// written to a file — the call's path, or a new one in WORK_FOLDER in the
|
|
1403
|
+
// folder the scan open now came from (workFolderNow), else in outDir —
|
|
1404
|
+
// which the text names.
|
|
1197
1405
|
own.push({
|
|
1198
1406
|
name: 'scanruler_batch',
|
|
1199
1407
|
title: 'Run several ScanRuler tools',
|
|
1200
1408
|
description:
|
|
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.`,
|
|
1409
|
+
`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 ${WORK_FOLDER} 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 (and file.folder, the ${WORK_FOLDER} folder); call the tool directly to see the picture. The server’s own tools (scanruler_status, _open, _wait, _batch, _write, _files, _read, _tools) cannot be batched.`,
|
|
1202
1410
|
inputSchema: {
|
|
1203
1411
|
type: 'object',
|
|
1204
1412
|
properties: {
|
|
@@ -1319,7 +1527,7 @@ export function createMcpServer({
|
|
|
1319
1527
|
retried = 'busy'
|
|
1320
1528
|
}
|
|
1321
1529
|
if (ctx && trace) {
|
|
1322
|
-
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 } : {}) })
|
|
1530
|
+
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, head: replyHead(one) } : {}) })
|
|
1323
1531
|
}
|
|
1324
1532
|
const ms = timings ? { ms: Date.now() - at } : {}
|
|
1325
1533
|
const again = retried ? { retried } : {}
|
|
@@ -1466,7 +1674,7 @@ export function createMcpServer({
|
|
|
1466
1674
|
hidden: [hiddenAt, hiddenNow()],
|
|
1467
1675
|
...(running ? { running } : {}),
|
|
1468
1676
|
...ctx.fields,
|
|
1469
|
-
...(trace.args ? { args: args ?? {} } : {}),
|
|
1677
|
+
...(trace.args ? { args: args ?? {}, head: replyHead(reply) } : {}),
|
|
1470
1678
|
})
|
|
1471
1679
|
return reply
|
|
1472
1680
|
})
|
|
@@ -1491,10 +1699,27 @@ export function createMcpServer({
|
|
|
1491
1699
|
// otherwise — and kept either way, for a client that stops waiting
|
|
1492
1700
|
// before it is done.
|
|
1493
1701
|
const command = commands().find((c) => toolName(c) === name)
|
|
1494
|
-
if (!command || (command.readOnly && !command.long))
|
|
1702
|
+
if (!command || (command.readOnly && !command.long)) {
|
|
1703
|
+
const answer = runPageTool(name, args, onProgress)
|
|
1704
|
+
callsInFlight.add(answer)
|
|
1705
|
+
return answer.finally(() => callsInFlight.delete(answer))
|
|
1706
|
+
}
|
|
1495
1707
|
const { wait, timings, ...rest } = args ?? {}
|
|
1496
1708
|
const timed = timings === true && !command.readOnly
|
|
1497
|
-
|
|
1709
|
+
// Busy with something of the page's own, as a batch's calls are: once it
|
|
1710
|
+
// is idle, the call is run again, once.
|
|
1711
|
+
const runOnce = (id) => runPageTool(name, rest, onProgress, { timings: timed, ms: timed, job: id })
|
|
1712
|
+
job = startJob(
|
|
1713
|
+
name,
|
|
1714
|
+
async (id) => {
|
|
1715
|
+
const first = await runOnce(id)
|
|
1716
|
+
if (replyMeasure(first).error !== 'busy') return first
|
|
1717
|
+
await pageIdle(Date.now() + IDLE_WAIT_MS)
|
|
1718
|
+
return runOnce(id)
|
|
1719
|
+
},
|
|
1720
|
+
ctx,
|
|
1721
|
+
wait === false,
|
|
1722
|
+
)
|
|
1498
1723
|
return wait === false ? queuedReply(job) : answerFor(job, ctx)
|
|
1499
1724
|
}
|
|
1500
1725
|
|