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