scanruler-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js CHANGED
@@ -20,7 +20,7 @@ export function configDir(env = process.env, platform = process.platform) {
20
20
  }
21
21
 
22
22
  /** A new token: 24 random bytes, URL-safe — it travels in a link. */
23
- export const newToken = () => randomBytes(24).toString('base64url')
23
+ const newToken = () => randomBytes(24).toString('base64url')
24
24
 
25
25
  /** The command list the last connected page sent, or null. */
26
26
  export function loadRemembered(dir = configDir()) {
package/src/link.js CHANGED
@@ -17,7 +17,7 @@ import { decoder, encode } from './framing.js'
17
17
 
18
18
  /** The hosted app. Loopback pages — a dev server, `--serve` — are allowed
19
19
  * on any port; anything else takes `--allow-origin`. */
20
- export const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
20
+ const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
21
21
 
22
22
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
23
23
 
@@ -43,7 +43,7 @@ const HEARTBEAT_MS = 15_000
43
43
  const HELLO_MS = 10_000
44
44
 
45
45
  /** Where a result carries a file's bytes, if it does. */
46
- export const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
46
+ const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
47
47
 
48
48
  /** A socket's frame, as the decoder takes it. */
49
49
  export const frameOf = (data, isBinary) => (isBinary ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : data.toString('utf8'))
package/src/main.js CHANGED
@@ -15,8 +15,8 @@ import { createMcpServer } from './mcp.js'
15
15
  import { SharedLink } from './relay.js'
16
16
  import { httpServer } from './serve.js'
17
17
 
18
- export const DEFAULT_PORT = 7317
19
- export const HOSTED_APP = 'https://scanruler.com/'
18
+ const DEFAULT_PORT = 7317
19
+ const HOSTED_APP = 'https://scanruler.com/'
20
20
 
21
21
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
22
22
 
@@ -71,7 +71,7 @@ export async function main(argv = process.argv.slice(2)) {
71
71
  const app = values.app ?? (values.serve ? `http://127.0.0.1:${port}/` : HOSTED_APP)
72
72
  const pairing = `${app.replace(/#.*$/, '')}#agent=${port}:${token}`
73
73
  const connectHelp = () =>
74
- `Open ScanRuler — ${app} — in Chrome or Edge and either open this pairing link once: ${pairing} — or in ScanRuler go to Settings → Agents, switch on "Local agent connection", and enter port ${port} and token ${token}.`
74
+ `Open ScanRuler — ${app} — in Chrome, Edge or Firefox and either open this pairing link once: ${pairing} — or in ScanRuler go to Settings → Agents, switch on "Local agent connection", and enter port ${port} and token ${token}.`
75
75
  if (values.pair) {
76
76
  process.stdout.write(`Port: ${port}\nToken: ${token}\nLink: ${pairing}\n`)
77
77
  return
package/src/mcp.js CHANGED
@@ -1,14 +1,15 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // The MCP side: the tools an agent lists and calls. Each of the page's
3
- // commands is a tool (tools.js); two more are this server's own —
3
+ // commands is a tool (tools.js); four more are this server's own —
4
4
  // scanruler_status, to see whether a ScanRuler page is connected and how to
5
5
  // connect one; scanruler_open, to open one in the user's browser, paired;
6
- // and scanruler_wait, to wait for it or for it to be idle.
6
+ // scanruler_wait, to wait for it or for it to be idle; and scanruler_batch,
7
+ // to run several of the page's tools in one call.
7
8
 
8
9
  import { readFileSync } from 'node:fs'
9
10
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
10
11
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
11
- import { imageContent, inputFor, saveFileResult, toolFor, toolName } from './tools.js'
12
+ import { agentJson, imageContent, inputFor, saveFileResult, toolFor, toolName } from './tools.js'
12
13
 
13
14
  /** The app's own commands as of this release, so the tools are there before
14
15
  * a page connects — unless a page has connected before, whose list was
@@ -17,16 +18,17 @@ import { imageContent, inputFor, saveFileResult, toolFor, toolName } from './too
17
18
  * (tests/mcpCommands.test.ts). */
18
19
  export const SNAPSHOT = JSON.parse(readFileSync(new URL('./commands.json', import.meta.url), 'utf8'))
19
20
 
20
- export const INSTRUCTIONS = [
21
+ const INSTRUCTIONS = [
21
22
  'These tools drive ScanRuler, a measuring app for 3D scans, in the browser tab the user has open: the user watches every step in the viewport and can undo it there.',
22
23
  'Start with scanruler_status. If no ScanRuler page is connected, call scanruler_open: it opens ScanRuler in the user’s browser, paired with this server, and waits for it. If that leaves it unconnected, tell the user what howToConnect says, then call scanruler_wait.',
23
- 'Read session_state before acting and after: the scan’s bounding box says where the part lies, the elements and dimensions what is measured. Lengths are millimetres, angles degrees.',
24
+ 'Read session_state before acting and after: the scan’s bounding box says where the part lies, the elements and dimensions what is measured. Lengths are millimetres, angles degrees; answers give numbers to six significant digits.',
24
25
  'A place on the scan is { point: [x, y, z] } (the nearest scan vertex is taken) or { vertex: n }. To fit a feature, aim at its middle, not at an edge.',
25
26
  'Every tool that changes the session is one step of the undo history; history_undo takes the last back. report_get gives the report as JSON (format "json") or as the text the panel copies (format "text").',
26
27
  'view_render shows you the 3D view as a picture; view_set turns it to a standard view or fits it.',
28
+ 'scanruler_batch runs several tools in one call, in order — many scan_nearest or element_fit calls, say — and stops at the first that is refused unless told to go on.',
27
29
  ].join(' ')
28
30
 
29
- const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] })
31
+ const text = (value, exact = false) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : agentJson(value, exact) }] })
30
32
  const failure = (e) => {
31
33
  const code = e?.code ?? 'failed'
32
34
  const message = e?.message ?? String(e)
@@ -178,15 +180,100 @@ export function createMcpServer({
178
180
  })
179
181
  }
180
182
 
181
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
182
- const { name, arguments: args } = request.params
183
- const mine = own.find((t) => t.name === name)
184
- if (mine) return mine.call(args)
183
+ /** One of the page's tools run on the page: the result as MCP content,
184
+ * or a failure. */
185
+ const runPageTool = async (name, args, onProgress) => {
185
186
  const command = commands().find((c) => toolName(c) === name)
186
187
  if (!command) return failure({ code: 'unknown_command', message: `There is no tool ${name}.` })
187
188
  if (!link.connected && !(await link.waitForPage(pageTimeoutMs))) {
188
189
  return failure({ code: 'unavailable', message: `No ScanRuler page is connected; scanruler_open opens one. ${connectHelp()}` })
189
190
  }
191
+ try {
192
+ const { input, binaryKey } = await inputFor(command, args, { cwd })
193
+ const result = await link.run(command.name, input, binaryKey, onProgress)
194
+ const image = command.returnsImage ? await imageContent(result, args, { cwd, outDir }) : null
195
+ if (image) return { content: image }
196
+ return text(await saveFileResult(result, args, { cwd, outDir }), Boolean(command.exact))
197
+ } catch (e) {
198
+ return failure(e)
199
+ }
200
+ }
201
+
202
+ // Several of the page's tools in one call — what cuts a run of twenty
203
+ // scan_nearest calls to one round trip. Each answer is the tool's own; a
204
+ // picture comes back as the text beside it (where it was written, if a
205
+ // path was given), not as an image.
206
+ own.push({
207
+ name: 'scanruler_batch',
208
+ title: 'Run several ScanRuler tools',
209
+ description:
210
+ 'Run several of the ScanRuler tools in one call, in order, each with its own arguments — a run of scan_nearest, element_fit or section_get calls, say. Returns results, one per call in order: { tool, ok, result } or { tool, ok: false, error: { code, message } }. The first refused stops the rest (stopped says which index) unless stopOnError is false. Each call that changes the session is still its own undo step. A tool that returns a picture gives its text here, the picture written to a file only if its call gave path. The server’s own tools (scanruler_status, _open, _wait, _batch) cannot be batched.',
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ calls: {
215
+ type: 'array',
216
+ minItems: 1,
217
+ maxItems: 200,
218
+ description: 'The calls, in order.',
219
+ items: {
220
+ type: 'object',
221
+ properties: {
222
+ tool: { type: 'string', minLength: 1, description: 'A tool’s name, as listed — scan_nearest, element_fit.' },
223
+ arguments: { type: 'object', description: 'Its arguments, as the tool takes them; {} for none.' },
224
+ },
225
+ required: ['tool'],
226
+ additionalProperties: false,
227
+ },
228
+ },
229
+ stopOnError: { type: 'boolean', description: 'Stop at the first refused call (the default), or run every call and report each.' },
230
+ },
231
+ required: ['calls'],
232
+ additionalProperties: false,
233
+ },
234
+ annotations: { readOnlyHint: false, openWorldHint: false },
235
+ call: async (args) => {
236
+ const calls = Array.isArray(args?.calls) ? args.calls : []
237
+ if (calls.length === 0) return failure({ code: 'invalid_input', message: 'calls: give at least one call.' })
238
+ const stopOnError = args?.stopOnError !== false
239
+ const results = []
240
+ let stopped
241
+ // A batch holding an exact answer (a plane's frame, say) keeps
242
+ // every answer exact: the rounding is one pass over the whole.
243
+ let exact = false
244
+ for (let i = 0; i < calls.length; i++) {
245
+ const c = calls[i] ?? {}
246
+ const tool = String(c.tool ?? '')
247
+ const one = own.some((t) => t.name === tool)
248
+ ? failure({ code: 'invalid_input', message: `${tool} is the server’s own and is not batched — call it on its own.` })
249
+ : await runPageTool(tool, c.arguments ?? {}, undefined)
250
+ if (!one.isError && commands().find((x) => toolName(x) === tool)?.exact) exact = true
251
+ const body = one.content.find((x) => x.type === 'text')?.text ?? ''
252
+ if (one.isError) {
253
+ const m = /^([a-z_]+): ([\s\S]*)$/.exec(body)
254
+ results.push({ tool, ok: false, error: m ? { code: m[1], message: m[2] } : { code: 'failed', message: body } })
255
+ if (stopOnError) {
256
+ stopped = i
257
+ break
258
+ }
259
+ } else {
260
+ let parsed
261
+ try {
262
+ parsed = JSON.parse(body)
263
+ } catch {
264
+ parsed = body
265
+ }
266
+ results.push({ tool, ok: true, result: parsed })
267
+ }
268
+ }
269
+ return text({ results, ran: results.length, of: calls.length, ...(stopped !== undefined ? { stopped } : {}) }, exact)
270
+ },
271
+ })
272
+
273
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
274
+ const { name, arguments: args } = request.params
275
+ const mine = own.find((t) => t.name === name)
276
+ if (mine) return mine.call(args)
190
277
  // Progress lines from the page, for a client that asked for them.
191
278
  const token = request.params._meta?.progressToken
192
279
  let step = 0
@@ -196,15 +283,7 @@ export function createMcpServer({
196
283
  : (line) => {
197
284
  extra.sendNotification({ method: 'notifications/progress', params: { progressToken: token, progress: ++step, message: line } }).catch(() => {})
198
285
  }
199
- try {
200
- const { input, binaryKey } = await inputFor(command, args, { cwd })
201
- const result = await link.run(command.name, input, binaryKey, onProgress)
202
- const image = command.returnsImage ? await imageContent(result, args, { cwd, outDir }) : null
203
- if (image) return { content: image }
204
- return text(await saveFileResult(result, args, { cwd, outDir }))
205
- } catch (e) {
206
- return failure(e)
207
- }
286
+ return runPageTool(name, args, onProgress)
208
287
  })
209
288
 
210
289
  return server
package/src/serve.js CHANGED
@@ -31,7 +31,7 @@ const TYPES = {
31
31
  }
32
32
 
33
33
  /** The file a URL path names under `root`, or null for one outside it. */
34
- export function fileFor(root, urlPath) {
34
+ function fileFor(root, urlPath) {
35
35
  const path = decodeURIComponent(urlPath.split('?')[0].split('#')[0])
36
36
  const file = resolve(root, normalize('.' + (path.endsWith('/') ? `${path}index.html` : path)))
37
37
  return file === root || file.startsWith(root + sep) ? file : null
package/src/tools.js CHANGED
@@ -37,6 +37,28 @@ const imagePath = {
37
37
  description: 'Also write the picture to a file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it is only shown.',
38
38
  }
39
39
 
40
+ /** How many significant digits a number keeps in an answer, and the finest
41
+ * step it is given to: six digits is a micrometre on a part under a metre
42
+ * and a millionth of a unit vector; past them the page's doubles are
43
+ * noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
44
+ const DIGITS = 6
45
+ const STEP = 1e6
46
+
47
+ const tidy = (v) => {
48
+ const r = Number(v.toPrecision(DIGITS))
49
+ return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
50
+ }
51
+
52
+ /** An answer as the agent reads it: JSON, every number that is not whole
53
+ * kept to DIGITS significant digits and no finer than a millionth — 1e-17,
54
+ * a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
55
+ * are as they were. An `exact` answer is one whose numbers are handed
56
+ * back as inputs — a plane's frame, say — and keeps every digit:
57
+ * shortened, what is built on them is a hair off what it was meant to
58
+ * meet. */
59
+ export const agentJson = (value, exact = false) =>
60
+ exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
61
+
40
62
  /** A command as the MCP tool an agent sees. */
41
63
  export function toolFor(command, { outDir }) {
42
64
  const input = structuredClone(command.input ?? { type: 'object' })
@@ -105,7 +127,7 @@ export async function imageContent(result, args, { cwd, outDir }) {
105
127
  if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
106
128
  const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
107
129
  return [
108
- { type: 'text', text: JSON.stringify(saved) },
130
+ { type: 'text', text: agentJson(saved) },
109
131
  { type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
110
132
  ]
111
133
  }