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/README.md +241 -228
- package/package.json +30 -30
- package/src/commands.json +2680 -2299
- package/src/config.js +1 -1
- package/src/link.js +2 -2
- package/src/main.js +3 -3
- package/src/mcp.js +98 -19
- package/src/serve.js +1 -1
- package/src/tools.js +23 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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);
|
|
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
|
-
//
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|