scanruler-mcp 0.4.5 → 0.5.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/package.json +41 -41
- package/src/commands.json +45 -9
- package/src/tools.js +379 -379
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "scanruler-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drive ScanRuler, the 3D-scan measuring tool, from an AI agent: a local MCP server that reaches the ScanRuler tab open in your browser.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "AGPL-3.0-only",
|
|
7
|
-
"author": "Stefan Hermann (CNC Kitchen) <stefan@cnckitchen.com>",
|
|
8
|
-
"homepage": "https://scanruler.com/",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/CNCKitchen/scanruler.git",
|
|
12
|
-
"directory": "mcp"
|
|
13
|
-
},
|
|
14
|
-
"keywords": [
|
|
15
|
-
"mcp",
|
|
16
|
-
"model-context-protocol",
|
|
17
|
-
"3d-scan",
|
|
18
|
-
"metrology",
|
|
19
|
-
"scanruler"
|
|
20
|
-
],
|
|
21
|
-
"bin": {
|
|
22
|
-
"scanruler-mcp": "bin/scanruler-mcp.js"
|
|
23
|
-
},
|
|
24
|
-
"files": [
|
|
25
|
-
"bin",
|
|
26
|
-
"src",
|
|
27
|
-
"README.md",
|
|
28
|
-
"LICENSE"
|
|
29
|
-
],
|
|
30
|
-
"engines": {
|
|
31
|
-
"node": ">=22"
|
|
32
|
-
},
|
|
33
|
-
"scripts": {
|
|
34
|
-
"start": "node bin/scanruler-mcp.js",
|
|
35
|
-
"test": "node --test"
|
|
36
|
-
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"@modelcontextprotocol/sdk": "1.32.1",
|
|
39
|
-
"ws": "8.22.0"
|
|
40
|
-
}
|
|
41
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "scanruler-mcp",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Drive ScanRuler, the 3D-scan measuring tool, from an AI agent: a local MCP server that reaches the ScanRuler tab open in your browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"author": "Stefan Hermann (CNC Kitchen) <stefan@cnckitchen.com>",
|
|
8
|
+
"homepage": "https://scanruler.com/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/CNCKitchen/scanruler.git",
|
|
12
|
+
"directory": "mcp"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"3d-scan",
|
|
18
|
+
"metrology",
|
|
19
|
+
"scanruler"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"scanruler-mcp": "bin/scanruler-mcp.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node bin/scanruler-mcp.js",
|
|
35
|
+
"test": "node --test"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
39
|
+
"ws": "8.22.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/commands.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"app": "0.8.
|
|
3
|
-
"saved": "2026-10-
|
|
2
|
+
"app": "0.8.3",
|
|
3
|
+
"saved": "2026-10-28T13:16:34.062Z",
|
|
4
4
|
"commands": [
|
|
5
5
|
{
|
|
6
6
|
"name": "align.auto",
|
|
@@ -434,7 +434,7 @@
|
|
|
434
434
|
{
|
|
435
435
|
"name": "deviation.open_reference",
|
|
436
436
|
"title": "Open the reference part",
|
|
437
|
-
"description": "Open the nominal part the scan is compared with — STEP straight from CAD, or STL, PLY or
|
|
437
|
+
"description": "Open the nominal part the scan is compared with — STEP straight from CAD, or STL, PLY, OBJ or 3MF — in place of the one there is; any alignment and map against the old one go. The elements measured on the scan stay. Returns the deviation state. Not undoable: a new reference starts a new history.",
|
|
438
438
|
"input": {
|
|
439
439
|
"type": "object",
|
|
440
440
|
"properties": {
|
|
@@ -1832,11 +1832,20 @@
|
|
|
1832
1832
|
},
|
|
1833
1833
|
{
|
|
1834
1834
|
"name": "export.stl",
|
|
1835
|
-
"title": "Export the scan as
|
|
1836
|
-
"description": "The scan as a binary STL in the pose it is shown in
|
|
1835
|
+
"title": "Export the scan as a mesh",
|
|
1836
|
+
"description": "The scan as a binary STL — or, format 3mf, a 3MF in millimetres with the scan’s own colours — in the pose it is shown in, a datum alignment and a best fit onto the reference included. Needs the viewport. Returns the file’s bytes and the name the panel would save it under.",
|
|
1837
1837
|
"input": {
|
|
1838
1838
|
"type": "object",
|
|
1839
|
-
"properties": {
|
|
1839
|
+
"properties": {
|
|
1840
|
+
"format": {
|
|
1841
|
+
"type": "string",
|
|
1842
|
+
"enum": [
|
|
1843
|
+
"stl",
|
|
1844
|
+
"3mf"
|
|
1845
|
+
],
|
|
1846
|
+
"description": "stl (the default) or 3mf."
|
|
1847
|
+
}
|
|
1848
|
+
},
|
|
1840
1849
|
"additionalProperties": false
|
|
1841
1850
|
},
|
|
1842
1851
|
"readOnly": true,
|
|
@@ -3346,7 +3355,7 @@
|
|
|
3346
3355
|
{
|
|
3347
3356
|
"name": "scan.open",
|
|
3348
3357
|
"title": "Open a scan",
|
|
3349
|
-
"description": "Open a mesh as the scan to measure — STL, PLY or
|
|
3358
|
+
"description": "Open a mesh as the scan to measure — STL, PLY, OBJ or 3MF, or a STEP part, cut into triangles a few hundred to the part’s diagonal — in place of the one there is; everything measured on the old one goes with it, so it refuses while the session holds measurements unless discard is true — the refusal names what the session holds. A reference part opened from a file stays, its alignment and maps gone (referenceKept names it); one the app made from the session’s own work goes with that work (referenceDropped). Returns the scan: name, units, vertex and triangle counts, its bounding box (min and max corners), the box’s centre and its diagonal (size), in millimetres. Not undoable: a new scan starts a new history.",
|
|
3350
3359
|
"input": {
|
|
3351
3360
|
"type": "object",
|
|
3352
3361
|
"properties": {
|
|
@@ -3914,7 +3923,7 @@
|
|
|
3914
3923
|
{
|
|
3915
3924
|
"name": "section.cut",
|
|
3916
3925
|
"title": "Cut a section",
|
|
3917
|
-
"description": "Cut the scan with a plane and keep the cut as a section, measured in the 2D workspace (flat) — where it is put on the sheet. across is what the plane is taken across: an element with a direction (a plane, cylinder, cone, line or circle) by id or name, or a coordinate plane \"XY\", \"YZ\" or \"XZ\" (also \"ZX\"), which is laid through the part’s centre. Or { axis, angleDeg }: a plane through an axis — a cylinder’s, cone’s, line’s or circle’s, by id or name, or the coordinate axis \"X\", \"Y\" or \"Z\" through the origin — turned angleDeg about it: a half section of a turned part, u the distance out from the axis and v up it (towards the + end of the coordinate axis it lies nearest, whichever way the element points), normal u × v, at every angle. 0° is the coordinate axis next round (X about Z, Y about X, Z about Y) and the angle turns by the right hand about the axis — about Z, from +X towards +Y. offset slides the plane along its normal, mm — from the element, or for a coordinate plane the coordinate itself; through an axis, 0 by default. Returns the section: its plane and how many chains the cut has. One undo step.",
|
|
3926
|
+
"description": "Cut the scan with a plane and keep the cut as a section, measured in the 2D workspace (flat) — where it is put on the sheet. across is what the plane is taken across: an element with a direction (a plane, cylinder, cone, line or circle) by id or name, or a coordinate plane \"XY\", \"YZ\" or \"XZ\" (also \"ZX\"), which is laid through the part’s centre. Or { axis, angleDeg }: a plane through an axis — a cylinder’s, cone’s, line’s or circle’s, by id or name, or the coordinate axis \"X\", \"Y\" or \"Z\" through the origin — turned angleDeg about it: a half section of a turned part, u the distance out from the axis and v up it (towards the + end of the coordinate axis it lies nearest, whichever way the element points), normal u × v, at every angle. 0° is the coordinate axis next round (X about Z, Y about X, Z about Y) and the angle turns by the right hand about the axis — about Z, from +X towards +Y. offset slides the plane along its normal, mm — from the element, or for a coordinate plane the coordinate itself; through an axis, 0 by default. kind \"shadow\" keeps the scan’s shadow instead of its cut: the whole part projected along the plane’s normal onto the plane, its outer boundary as closed loops (holes marked) — the widest outline, every indentation filled, for a bin, a foam cutout or a pocket — read as lines and arcs to tolerance (mm, 0.1 by default) and lying between play and play + 2·tolerance outside the part’s exact silhouette (play in mm, 1 by default; 0 for none, negative is a press fit); where two lines meet turning by less than smoothDeg (30° by default, 0 for none) the corner is rounded by an arc tangent to both, sharper ones kept; the loops are drawn in the middle of the part’s thickness along the normal, and a shadow takes no offset; extrude (true by default; false keeps it flat) stands it up as a block through the part’s thickness along the normal (the part’s lowest point to its highest), drawn in 3D and written by export.step as a solid in the solids form, a planar face per end and a face per line or arc. Returns the section: its plane and how many chains the cut has — for a shadow also its loops, area and the band it lies in. One undo step.",
|
|
3918
3927
|
"input": {
|
|
3919
3928
|
"type": "object",
|
|
3920
3929
|
"properties": {
|
|
@@ -3965,12 +3974,39 @@
|
|
|
3965
3974
|
},
|
|
3966
3975
|
"offset": {
|
|
3967
3976
|
"type": "number",
|
|
3968
|
-
"description": "Where along the normal the plane lies, mm."
|
|
3977
|
+
"description": "Where along the normal the plane lies, mm. A cut’s only: a shadow is drawn in the middle of the part."
|
|
3969
3978
|
},
|
|
3970
3979
|
"name": {
|
|
3971
3980
|
"type": "string",
|
|
3972
3981
|
"description": "A name for it; otherwise it is numbered — \"Section 2\".",
|
|
3973
3982
|
"minLength": 1
|
|
3983
|
+
},
|
|
3984
|
+
"kind": {
|
|
3985
|
+
"type": "string",
|
|
3986
|
+
"enum": [
|
|
3987
|
+
"cut",
|
|
3988
|
+
"shadow"
|
|
3989
|
+
],
|
|
3990
|
+
"description": "cut (the default): the scan where the plane passes through it; shadow: the whole scan projected onto the plane."
|
|
3991
|
+
},
|
|
3992
|
+
"play": {
|
|
3993
|
+
"type": "number",
|
|
3994
|
+
"description": "A shadow’s clearance outside the part, mm; negative is a press fit. 1 by default."
|
|
3995
|
+
},
|
|
3996
|
+
"tolerance": {
|
|
3997
|
+
"type": "number",
|
|
3998
|
+
"description": "What a shadow’s outline is read to as lines and arcs, mm. 0.1 by default.",
|
|
3999
|
+
"exclusiveMinimum": 0
|
|
4000
|
+
},
|
|
4001
|
+
"smoothDeg": {
|
|
4002
|
+
"type": "number",
|
|
4003
|
+
"description": "A shadow’s corners turning by less than this are rounded, degrees; 30 by default, 0 keeps every corner sharp.",
|
|
4004
|
+
"minimum": 0,
|
|
4005
|
+
"maximum": 180
|
|
4006
|
+
},
|
|
4007
|
+
"extrude": {
|
|
4008
|
+
"type": "boolean",
|
|
4009
|
+
"description": "A shadow stood up through the part’s thickness as a block, for the STEP export. true by default."
|
|
3974
4010
|
}
|
|
3975
4011
|
},
|
|
3976
4012
|
"required": [
|
package/src/tools.js
CHANGED
|
@@ -1,379 +1,379 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// The page's commands as MCP tools. A command's name becomes the tool's,
|
|
3
|
-
// dots to underscores — element.fit is element_fit — and its JSON Schema
|
|
4
|
-
// passes through, but for files: a command that takes a file's bytes takes a
|
|
5
|
-
// path on this computer instead, and this server reads the file and streams
|
|
6
|
-
// it to the page; a command that hands a file back takes an optional path,
|
|
7
|
-
// and this server writes the file there. The agent never sees the bytes —
|
|
8
|
-
// but for a picture (a command marked returnsImage), which it is shown as an
|
|
9
|
-
// image, and which is written to a file only when it gives a path — or, in
|
|
10
|
-
// a batch, where it cannot be shown, always.
|
|
11
|
-
|
|
12
|
-
import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
|
|
13
|
-
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
14
|
-
|
|
15
|
-
export const toolName = (command) => command.name.replace(/\./g, '_')
|
|
16
|
-
|
|
17
|
-
/** The input property that carries a file's bytes, if the command has one. */
|
|
18
|
-
export function bytesProperty(command) {
|
|
19
|
-
const props = command.input?.properties ?? {}
|
|
20
|
-
return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Where a relative path is taken from, as every path field says it: the
|
|
24
|
-
* folder a scan or a project was last opened from or saved into — as
|
|
25
|
-
* scanruler_write and scanruler_files take it — else the folder the
|
|
26
|
-
* server started in (mcp.js, pathBase). */
|
|
27
|
-
export const relativeFrom = (cwd) =>
|
|
28
|
-
`A relative path is taken from the folder of the scan or project last opened or saved (scanruler_status: server.relativeTo) — before any, from ${cwd ?? 'the folder this server started in'}.`
|
|
29
|
-
|
|
30
|
-
const inPath = (cwd) => ({
|
|
31
|
-
type: 'string',
|
|
32
|
-
minLength: 1,
|
|
33
|
-
description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
const outPath = (outDir, cwd) => ({
|
|
37
|
-
type: 'string',
|
|
38
|
-
minLength: 1,
|
|
39
|
-
description: `Where to write the file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a file of that name there (-2, -3 …). Without it, it goes into ${outDir} the same way. ${relativeFrom(cwd)}`,
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
const imagePath = (outDir, cwd) => ({
|
|
43
|
-
type: 'string',
|
|
44
|
-
minLength: 1,
|
|
45
|
-
description: `Also write the picture to a file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a picture of that name there (-2, -3 …). Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written under a new name into the folder of the scan or project last opened or saved (the latest of scanruler_status folders), before any into ${outDir}. ${relativeFrom(cwd)}`,
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
/** The `saveTo` a command that replaces the session takes — one with
|
|
49
|
-
* `discard`: the session saved as a project file first (mcp.js). */
|
|
50
|
-
const saveToIn = (cwd) => ({
|
|
51
|
-
type: 'string',
|
|
52
|
-
minLength: 1,
|
|
53
|
-
description: `Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo). ${relativeFrom(cwd)}`,
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
/** Whether a command replaces the session, refusing to over work unless
|
|
57
|
-
* told `discard` — what `saveTo` is offered on. */
|
|
58
|
-
export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
|
|
59
|
-
|
|
60
|
-
const inlineIn = {
|
|
61
|
-
type: 'boolean',
|
|
62
|
-
description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** How many significant digits a number keeps in an answer, and the finest
|
|
66
|
-
* step it is given to: six digits is a micrometre on a part under a metre
|
|
67
|
-
* and a millionth of a unit vector; past them the page's doubles are
|
|
68
|
-
* noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
|
|
69
|
-
const DIGITS = 6
|
|
70
|
-
const STEP = 1e6
|
|
71
|
-
|
|
72
|
-
const tidy = (v) => {
|
|
73
|
-
const r = Number(v.toPrecision(DIGITS))
|
|
74
|
-
return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** An answer as the agent reads it: JSON, every number that is not whole
|
|
78
|
-
* kept to DIGITS significant digits and no finer than a millionth — 1e-17,
|
|
79
|
-
* a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
|
|
80
|
-
* are as they were. An `exact` answer is one whose numbers are handed
|
|
81
|
-
* back as inputs — a plane's frame, say — and keeps every digit:
|
|
82
|
-
* shortened, what is built on them is a hair off what it was meant to
|
|
83
|
-
* meet. */
|
|
84
|
-
export const agentJson = (value, exact = false) =>
|
|
85
|
-
exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
|
|
86
|
-
|
|
87
|
-
/** A command as the MCP tool an agent sees. */
|
|
88
|
-
/** The `wait` a tool that changes the session takes, and a long read
|
|
89
|
-
* (Command.long): the server runs every such call as a job (mcp.js), and
|
|
90
|
-
* false answers with the job at once. */
|
|
91
|
-
export const waitIn = {
|
|
92
|
-
type: 'boolean',
|
|
93
|
-
description: 'false: answer at once with { queued: true, job } while the call runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done — or, still running at the server’s answer limit (scanruler_status: server.answerWithin, seconds), with { queued: true, job, after } all the same.',
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The `timings` a tool that changes the session takes: the server keeps
|
|
97
|
-
* what the page's answer carries of the build's steps (mcp.js). */
|
|
98
|
-
export const timingsIn = {
|
|
99
|
-
type: 'boolean',
|
|
100
|
-
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).',
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function toolFor(command, { outDir, cwd }) {
|
|
104
|
-
const input = structuredClone(command.input ?? { type: 'object' })
|
|
105
|
-
input.properties = { ...(input.properties ?? {}) }
|
|
106
|
-
let required = [...(input.required ?? [])]
|
|
107
|
-
const bytesKey = bytesProperty(command)
|
|
108
|
-
let description = command.description
|
|
109
|
-
if (bytesKey) {
|
|
110
|
-
delete input.properties[bytesKey]
|
|
111
|
-
const pick = FOLDER_PICKS[command.name]
|
|
112
|
-
const read = inPath(cwd)
|
|
113
|
-
input.properties.path = pick ? { ...read, description: `${read.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : read
|
|
114
|
-
required = required.filter((key) => key !== bytesKey && key !== 'name')
|
|
115
|
-
if (!required.includes('path')) required.unshift('path')
|
|
116
|
-
if (input.properties.name) {
|
|
117
|
-
input.properties.name = { ...input.properties.name, description: `${input.properties.name.description ?? ''} Defaults to the file's own name.`.trim() }
|
|
118
|
-
}
|
|
119
|
-
description += ' The file is read from `path` on this computer and streamed to the page.'
|
|
120
|
-
}
|
|
121
|
-
if (command.returnsImage) {
|
|
122
|
-
input.properties.path = imagePath(outDir, cwd)
|
|
123
|
-
input.properties.inline = inlineIn
|
|
124
|
-
description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
|
|
125
|
-
} else if (command.returnsFile) {
|
|
126
|
-
input.properties.path = outPath(outDir, cwd)
|
|
127
|
-
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
128
|
-
}
|
|
129
|
-
if (replacesSession(command)) {
|
|
130
|
-
input.properties.saveTo = saveToIn(cwd)
|
|
131
|
-
description += ' With `saveTo`, the session is saved as a project file there first (as project_save does), then replaced as with discard: true — one call to keep what a page holds and start anew.'
|
|
132
|
-
}
|
|
133
|
-
if (!command.readOnly) {
|
|
134
|
-
input.properties.wait = waitIn
|
|
135
|
-
input.properties.timings = timingsIn
|
|
136
|
-
} else if (command.long) {
|
|
137
|
-
// A long read runs as a job too (mcp.js), so it takes wait.
|
|
138
|
-
input.properties.wait = waitIn
|
|
139
|
-
}
|
|
140
|
-
if (required.length) input.required = required
|
|
141
|
-
else delete input.required
|
|
142
|
-
return {
|
|
143
|
-
name: toolName(command),
|
|
144
|
-
title: command.title,
|
|
145
|
-
description,
|
|
146
|
-
inputSchema: input,
|
|
147
|
-
annotations: { title: command.title, readOnlyHint: Boolean(command.readOnly) && (!command.returnsFile || Boolean(command.returnsImage)), openWorldHint: false },
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** A path as the agent gave it, made absolute against `base`. */
|
|
152
|
-
export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
|
|
153
|
-
|
|
154
|
-
/** What a folder given in place of a file opens, by command: the one file
|
|
155
|
-
* of its kind in it. Other commands take a file only. */
|
|
156
|
-
const FOLDER_PICKS = {
|
|
157
|
-
'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj'] },
|
|
158
|
-
'project.load': { what: 'project', kinds: ['.scanruler'] },
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The file a path names: itself, or — for a folder, where the command
|
|
162
|
-
* takes one (FOLDER_PICKS) — the only file of the command's kind in it.
|
|
163
|
-
* Refused with the folder's files listed otherwise. */
|
|
164
|
-
async function fileAt(command, file) {
|
|
165
|
-
const found = await stat(file).catch(() => null)
|
|
166
|
-
if (!found?.isDirectory()) return file
|
|
167
|
-
const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
|
|
168
|
-
const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
|
|
169
|
-
const pick = FOLDER_PICKS[command.name]
|
|
170
|
-
if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
|
|
171
|
-
const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
|
|
172
|
-
if (matches.length === 1) return join(file, matches[0])
|
|
173
|
-
if (matches.length === 0) {
|
|
174
|
-
throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
|
|
175
|
-
}
|
|
176
|
-
throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** How many near names a missing path's refusal lists at most, and how many
|
|
180
|
-
* letters off a near name may be. */
|
|
181
|
-
const NEAR_LISTED = 5
|
|
182
|
-
const NEAR_LETTERS = 2
|
|
183
|
-
|
|
184
|
-
/** A name as nearNames compares it: case ignored, and "-", "_" and a
|
|
185
|
-
* space taken alike. */
|
|
186
|
-
const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
|
|
187
|
-
|
|
188
|
-
/** How many letters `a` and `b` are apart — added, taken out or changed —
|
|
189
|
-
* or `most + 1` once past `most`. */
|
|
190
|
-
function lettersApart(a, b, most) {
|
|
191
|
-
if (Math.abs(a.length - b.length) > most) return most + 1
|
|
192
|
-
let row = Array.from({ length: b.length + 1 }, (_, j) => j)
|
|
193
|
-
for (let i = 1; i <= a.length; i++) {
|
|
194
|
-
const next = [i]
|
|
195
|
-
for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
196
|
-
if (Math.min(...next) > most) return most + 1
|
|
197
|
-
row = next
|
|
198
|
-
}
|
|
199
|
-
return row[b.length]
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Where a path that is not there may have been meant: the first folder up
|
|
203
|
-
* from it that is there, the name in the path missing from it, and up to
|
|
204
|
-
* NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
|
|
205
|
-
* path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
|
|
206
|
-
* given with the rest of the path when that is in it. */
|
|
207
|
-
export async function nearNames(path) {
|
|
208
|
-
let missing = resolve(path)
|
|
209
|
-
let folder = dirname(missing)
|
|
210
|
-
let rest = []
|
|
211
|
-
for (;;) {
|
|
212
|
-
if ((await stat(folder).catch(() => null))?.isDirectory()) break
|
|
213
|
-
if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
|
|
214
|
-
rest = [basename(missing), ...rest]
|
|
215
|
-
missing = folder
|
|
216
|
-
folder = dirname(folder)
|
|
217
|
-
}
|
|
218
|
-
const want = nearForm(basename(missing))
|
|
219
|
-
const names = await readdir(folder).catch(() => [])
|
|
220
|
-
const near = names
|
|
221
|
-
.map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
|
|
222
|
-
.filter((n) => n.apart <= NEAR_LETTERS)
|
|
223
|
-
.sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
|
|
224
|
-
.slice(0, NEAR_LISTED)
|
|
225
|
-
const paths = []
|
|
226
|
-
for (const { name } of near) {
|
|
227
|
-
const whole = join(folder, name, ...rest)
|
|
228
|
-
paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
|
|
229
|
-
}
|
|
230
|
-
return { folder, missing: basename(missing), near: paths }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** nearNames as a refusal's last sentence. */
|
|
234
|
-
async function nearNote(path) {
|
|
235
|
-
const { folder, missing, near } = await nearNames(path)
|
|
236
|
-
if (!folder) return ''
|
|
237
|
-
if (near.length) return ` Near it: ${near.join(', ')}.`
|
|
238
|
-
return ` ${folder} is there, with nothing in it named like ${missing}.`
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** The tool call's arguments as the command's input: a path read into the
|
|
242
|
-
* bytes it names. Returns the input, the input field the bytes go in, and
|
|
243
|
-
* the file read — a folder's one file, when the path named a folder. */
|
|
244
|
-
export async function inputFor(command, args, { cwd }) {
|
|
245
|
-
const input = { ...(args ?? {}) }
|
|
246
|
-
delete input.saveTo
|
|
247
|
-
const bytesKey = bytesProperty(command)
|
|
248
|
-
if (bytesKey) {
|
|
249
|
-
const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
|
|
250
|
-
delete input.path
|
|
251
|
-
let bytes
|
|
252
|
-
try {
|
|
253
|
-
bytes = new Uint8Array(await readFile(file))
|
|
254
|
-
} catch (e) {
|
|
255
|
-
if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
|
|
256
|
-
throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
|
|
257
|
-
}
|
|
258
|
-
input[bytesKey] = bytes
|
|
259
|
-
if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
|
|
260
|
-
return { input, binaryKey: bytesKey, file }
|
|
261
|
-
}
|
|
262
|
-
if (command.returnsFile || command.returnsImage) delete input.path
|
|
263
|
-
if (command.returnsImage) delete input.inline
|
|
264
|
-
return { input, binaryKey: null, file: null }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Arguments with every `{ "$file": path }` in them — an object of that one
|
|
268
|
-
* key, at any depth — replaced by the JSON the file holds: a long list of
|
|
269
|
-
* stations a script wrote need not pass through the conversation. A
|
|
270
|
-
* relative path is taken from `cwd` — the folder the server takes
|
|
271
|
-
* relative paths from (mcp.js, pathBase). What a file holds is not looked
|
|
272
|
-
* into again. */
|
|
273
|
-
export async function withFiles(value, cwd) {
|
|
274
|
-
if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
|
|
275
|
-
if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
|
|
276
|
-
const keys = Object.keys(value)
|
|
277
|
-
if (keys.length === 1 && keys[0] === '$file') {
|
|
278
|
-
if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
|
|
279
|
-
const path = absolute(value.$file, cwd)
|
|
280
|
-
try {
|
|
281
|
-
return JSON.parse(await readFile(path, 'utf8'))
|
|
282
|
-
} catch (e) {
|
|
283
|
-
throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const out = {}
|
|
287
|
-
for (const key of keys) out[key] = await withFiles(value[key], cwd)
|
|
288
|
-
return out
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** A file's kind by its extension, as scanruler_files lists it. */
|
|
292
|
-
const KINDS = {
|
|
293
|
-
mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
|
|
294
|
-
project: ['.scanruler'],
|
|
295
|
-
step: ['.step', '.stp'],
|
|
296
|
-
picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
|
|
297
|
-
drawing: ['.dxf', '.svg', '.pdf'],
|
|
298
|
-
text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
|
|
299
|
-
}
|
|
300
|
-
export const fileKind = (name) => {
|
|
301
|
-
const ext = extname(name).toLowerCase()
|
|
302
|
-
return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** A picture a command handed back, as MCP content: the image, and the rest
|
|
306
|
-
* of the result as text — where the picture was written, if the call gave
|
|
307
|
-
* a path, in place of its bytes; written and not shown with `inline:
|
|
308
|
-
* false`. Null for a result with no picture in it. */
|
|
309
|
-
export async function imageContent(result, args, { cwd, outDir }) {
|
|
310
|
-
const file = result?.file
|
|
311
|
-
if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
|
|
312
|
-
const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
|
|
313
|
-
if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
|
|
314
|
-
return [
|
|
315
|
-
{ type: 'text', text: agentJson(saved) },
|
|
316
|
-
{ type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
|
|
317
|
-
]
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** How many numbered names writeNew tries before it gives up. */
|
|
321
|
-
const NUMBERED_MOST = 10_000
|
|
322
|
-
|
|
323
|
-
/** `data` written to a new file in `folder` under `name` — or, a file of
|
|
324
|
-
* that name being there, as `stem-2.ext`, `stem-3.ext` …: each is made
|
|
325
|
-
* with `wx`, which refuses one that is there, so two written at once —
|
|
326
|
-
* pictures rendered side by side, answers spilled together — never take
|
|
327
|
-
* the same name, and nothing earlier is written over. Returns its path. */
|
|
328
|
-
export async function writeNew(folder, name, data) {
|
|
329
|
-
await mkdir(folder, { recursive: true })
|
|
330
|
-
const dot = name.lastIndexOf('.')
|
|
331
|
-
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
|
|
332
|
-
for (let n = 1; n <= NUMBERED_MOST; n++) {
|
|
333
|
-
const target = join(folder, n === 1 ? name : `${stem}-${n}${ext}`)
|
|
334
|
-
try {
|
|
335
|
-
await writeFile(target, data, { flag: 'wx' })
|
|
336
|
-
return target
|
|
337
|
-
} catch (e) {
|
|
338
|
-
if (e?.code !== 'EEXIST') throw e
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
throw { code: 'failed', message: `${folder} already holds ${NUMBERED_MOST} files named like ${name}: give a path to write it to.` }
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** A picture a batched call handed back, which a batch cannot show:
|
|
345
|
-
* written where the call asked, or into `folder` — the latest a scan or
|
|
346
|
-
* a project came from, which a sandboxed agent can open — else, or when
|
|
347
|
-
* that folder takes no file, into `outDir`; as saveFileResult writes any
|
|
348
|
-
* file, numbered past one already there, so a batch of renders, or a
|
|
349
|
-
* render from an earlier batch, is not written over. The result says
|
|
350
|
-
* where, in place of the bytes. */
|
|
351
|
-
export async function batchedPicture(result, args, { cwd, outDir, folder }) {
|
|
352
|
-
if (args?.path || !folder) return saveFileResult(result, args, { cwd, outDir })
|
|
353
|
-
try {
|
|
354
|
-
return await saveFileResult(result, args, { cwd, outDir: folder })
|
|
355
|
-
} catch {
|
|
356
|
-
return saveFileResult(result, args, { cwd, outDir })
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Write a returned file where the call asked, or into `outDir`; the result
|
|
361
|
-
* then says where, in place of the bytes. A file path given is written,
|
|
362
|
-
* over one there; a folder — one there, or a path ending in a separator —
|
|
363
|
-
* or `outDir` takes the name ScanRuler gave the file, numbered past one
|
|
364
|
-
* already there (writeNew). */
|
|
365
|
-
export async function saveFileResult(result, args, { cwd, outDir }) {
|
|
366
|
-
const file = result?.file
|
|
367
|
-
if (!file || !(file.bytes instanceof Uint8Array)) return result
|
|
368
|
-
const name = basename(String(file.name ?? 'file'))
|
|
369
|
-
const given = args?.path ? absolute(String(args.path), cwd) : null
|
|
370
|
-
const folder = given === null ? outDir : /[\\/]$/.test(String(args.path)) || (await stat(given).catch(() => null))?.isDirectory() ? given : null
|
|
371
|
-
let target
|
|
372
|
-
if (folder !== null) target = await writeNew(folder, name, file.bytes)
|
|
373
|
-
else {
|
|
374
|
-
target = given
|
|
375
|
-
await mkdir(dirname(target), { recursive: true })
|
|
376
|
-
await writeFile(target, file.bytes)
|
|
377
|
-
}
|
|
378
|
-
return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
|
|
379
|
-
}
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// The page's commands as MCP tools. A command's name becomes the tool's,
|
|
3
|
+
// dots to underscores — element.fit is element_fit — and its JSON Schema
|
|
4
|
+
// passes through, but for files: a command that takes a file's bytes takes a
|
|
5
|
+
// path on this computer instead, and this server reads the file and streams
|
|
6
|
+
// it to the page; a command that hands a file back takes an optional path,
|
|
7
|
+
// and this server writes the file there. The agent never sees the bytes —
|
|
8
|
+
// but for a picture (a command marked returnsImage), which it is shown as an
|
|
9
|
+
// image, and which is written to a file only when it gives a path — or, in
|
|
10
|
+
// a batch, where it cannot be shown, always.
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
|
|
13
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
14
|
+
|
|
15
|
+
export const toolName = (command) => command.name.replace(/\./g, '_')
|
|
16
|
+
|
|
17
|
+
/** The input property that carries a file's bytes, if the command has one. */
|
|
18
|
+
export function bytesProperty(command) {
|
|
19
|
+
const props = command.input?.properties ?? {}
|
|
20
|
+
return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where a relative path is taken from, as every path field says it: the
|
|
24
|
+
* folder a scan or a project was last opened from or saved into — as
|
|
25
|
+
* scanruler_write and scanruler_files take it — else the folder the
|
|
26
|
+
* server started in (mcp.js, pathBase). */
|
|
27
|
+
export const relativeFrom = (cwd) =>
|
|
28
|
+
`A relative path is taken from the folder of the scan or project last opened or saved (scanruler_status: server.relativeTo) — before any, from ${cwd ?? 'the folder this server started in'}.`
|
|
29
|
+
|
|
30
|
+
const inPath = (cwd) => ({
|
|
31
|
+
type: 'string',
|
|
32
|
+
minLength: 1,
|
|
33
|
+
description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const outPath = (outDir, cwd) => ({
|
|
37
|
+
type: 'string',
|
|
38
|
+
minLength: 1,
|
|
39
|
+
description: `Where to write the file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a file of that name there (-2, -3 …). Without it, it goes into ${outDir} the same way. ${relativeFrom(cwd)}`,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const imagePath = (outDir, cwd) => ({
|
|
43
|
+
type: 'string',
|
|
44
|
+
minLength: 1,
|
|
45
|
+
description: `Also write the picture to a file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a picture of that name there (-2, -3 …). Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written under a new name into the folder of the scan or project last opened or saved (the latest of scanruler_status folders), before any into ${outDir}. ${relativeFrom(cwd)}`,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** The `saveTo` a command that replaces the session takes — one with
|
|
49
|
+
* `discard`: the session saved as a project file first (mcp.js). */
|
|
50
|
+
const saveToIn = (cwd) => ({
|
|
51
|
+
type: 'string',
|
|
52
|
+
minLength: 1,
|
|
53
|
+
description: `Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo). ${relativeFrom(cwd)}`,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Whether a command replaces the session, refusing to over work unless
|
|
57
|
+
* told `discard` — what `saveTo` is offered on. */
|
|
58
|
+
export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
|
|
59
|
+
|
|
60
|
+
const inlineIn = {
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How many significant digits a number keeps in an answer, and the finest
|
|
66
|
+
* step it is given to: six digits is a micrometre on a part under a metre
|
|
67
|
+
* and a millionth of a unit vector; past them the page's doubles are
|
|
68
|
+
* noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
|
|
69
|
+
const DIGITS = 6
|
|
70
|
+
const STEP = 1e6
|
|
71
|
+
|
|
72
|
+
const tidy = (v) => {
|
|
73
|
+
const r = Number(v.toPrecision(DIGITS))
|
|
74
|
+
return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An answer as the agent reads it: JSON, every number that is not whole
|
|
78
|
+
* kept to DIGITS significant digits and no finer than a millionth — 1e-17,
|
|
79
|
+
* a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
|
|
80
|
+
* are as they were. An `exact` answer is one whose numbers are handed
|
|
81
|
+
* back as inputs — a plane's frame, say — and keeps every digit:
|
|
82
|
+
* shortened, what is built on them is a hair off what it was meant to
|
|
83
|
+
* meet. */
|
|
84
|
+
export const agentJson = (value, exact = false) =>
|
|
85
|
+
exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
|
|
86
|
+
|
|
87
|
+
/** A command as the MCP tool an agent sees. */
|
|
88
|
+
/** The `wait` a tool that changes the session takes, and a long read
|
|
89
|
+
* (Command.long): the server runs every such call as a job (mcp.js), and
|
|
90
|
+
* false answers with the job at once. */
|
|
91
|
+
export const waitIn = {
|
|
92
|
+
type: 'boolean',
|
|
93
|
+
description: 'false: answer at once with { queued: true, job } while the call runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done — or, still running at the server’s answer limit (scanruler_status: server.answerWithin, seconds), with { queued: true, job, after } all the same.',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The `timings` a tool that changes the session takes: the server keeps
|
|
97
|
+
* what the page's answer carries of the build's steps (mcp.js). */
|
|
98
|
+
export const timingsIn = {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
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).',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toolFor(command, { outDir, cwd }) {
|
|
104
|
+
const input = structuredClone(command.input ?? { type: 'object' })
|
|
105
|
+
input.properties = { ...(input.properties ?? {}) }
|
|
106
|
+
let required = [...(input.required ?? [])]
|
|
107
|
+
const bytesKey = bytesProperty(command)
|
|
108
|
+
let description = command.description
|
|
109
|
+
if (bytesKey) {
|
|
110
|
+
delete input.properties[bytesKey]
|
|
111
|
+
const pick = FOLDER_PICKS[command.name]
|
|
112
|
+
const read = inPath(cwd)
|
|
113
|
+
input.properties.path = pick ? { ...read, description: `${read.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : read
|
|
114
|
+
required = required.filter((key) => key !== bytesKey && key !== 'name')
|
|
115
|
+
if (!required.includes('path')) required.unshift('path')
|
|
116
|
+
if (input.properties.name) {
|
|
117
|
+
input.properties.name = { ...input.properties.name, description: `${input.properties.name.description ?? ''} Defaults to the file's own name.`.trim() }
|
|
118
|
+
}
|
|
119
|
+
description += ' The file is read from `path` on this computer and streamed to the page.'
|
|
120
|
+
}
|
|
121
|
+
if (command.returnsImage) {
|
|
122
|
+
input.properties.path = imagePath(outDir, cwd)
|
|
123
|
+
input.properties.inline = inlineIn
|
|
124
|
+
description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
|
|
125
|
+
} else if (command.returnsFile) {
|
|
126
|
+
input.properties.path = outPath(outDir, cwd)
|
|
127
|
+
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
128
|
+
}
|
|
129
|
+
if (replacesSession(command)) {
|
|
130
|
+
input.properties.saveTo = saveToIn(cwd)
|
|
131
|
+
description += ' With `saveTo`, the session is saved as a project file there first (as project_save does), then replaced as with discard: true — one call to keep what a page holds and start anew.'
|
|
132
|
+
}
|
|
133
|
+
if (!command.readOnly) {
|
|
134
|
+
input.properties.wait = waitIn
|
|
135
|
+
input.properties.timings = timingsIn
|
|
136
|
+
} else if (command.long) {
|
|
137
|
+
// A long read runs as a job too (mcp.js), so it takes wait.
|
|
138
|
+
input.properties.wait = waitIn
|
|
139
|
+
}
|
|
140
|
+
if (required.length) input.required = required
|
|
141
|
+
else delete input.required
|
|
142
|
+
return {
|
|
143
|
+
name: toolName(command),
|
|
144
|
+
title: command.title,
|
|
145
|
+
description,
|
|
146
|
+
inputSchema: input,
|
|
147
|
+
annotations: { title: command.title, readOnlyHint: Boolean(command.readOnly) && (!command.returnsFile || Boolean(command.returnsImage)), openWorldHint: false },
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A path as the agent gave it, made absolute against `base`. */
|
|
152
|
+
export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
|
|
153
|
+
|
|
154
|
+
/** What a folder given in place of a file opens, by command: the one file
|
|
155
|
+
* of its kind in it. Other commands take a file only. */
|
|
156
|
+
const FOLDER_PICKS = {
|
|
157
|
+
'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj', '.3mf'] },
|
|
158
|
+
'project.load': { what: 'project', kinds: ['.scanruler'] },
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The file a path names: itself, or — for a folder, where the command
|
|
162
|
+
* takes one (FOLDER_PICKS) — the only file of the command's kind in it.
|
|
163
|
+
* Refused with the folder's files listed otherwise. */
|
|
164
|
+
async function fileAt(command, file) {
|
|
165
|
+
const found = await stat(file).catch(() => null)
|
|
166
|
+
if (!found?.isDirectory()) return file
|
|
167
|
+
const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
|
|
168
|
+
const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
|
|
169
|
+
const pick = FOLDER_PICKS[command.name]
|
|
170
|
+
if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
|
|
171
|
+
const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
|
|
172
|
+
if (matches.length === 1) return join(file, matches[0])
|
|
173
|
+
if (matches.length === 0) {
|
|
174
|
+
throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
|
|
175
|
+
}
|
|
176
|
+
throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How many near names a missing path's refusal lists at most, and how many
|
|
180
|
+
* letters off a near name may be. */
|
|
181
|
+
const NEAR_LISTED = 5
|
|
182
|
+
const NEAR_LETTERS = 2
|
|
183
|
+
|
|
184
|
+
/** A name as nearNames compares it: case ignored, and "-", "_" and a
|
|
185
|
+
* space taken alike. */
|
|
186
|
+
const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
|
|
187
|
+
|
|
188
|
+
/** How many letters `a` and `b` are apart — added, taken out or changed —
|
|
189
|
+
* or `most + 1` once past `most`. */
|
|
190
|
+
function lettersApart(a, b, most) {
|
|
191
|
+
if (Math.abs(a.length - b.length) > most) return most + 1
|
|
192
|
+
let row = Array.from({ length: b.length + 1 }, (_, j) => j)
|
|
193
|
+
for (let i = 1; i <= a.length; i++) {
|
|
194
|
+
const next = [i]
|
|
195
|
+
for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
196
|
+
if (Math.min(...next) > most) return most + 1
|
|
197
|
+
row = next
|
|
198
|
+
}
|
|
199
|
+
return row[b.length]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Where a path that is not there may have been meant: the first folder up
|
|
203
|
+
* from it that is there, the name in the path missing from it, and up to
|
|
204
|
+
* NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
|
|
205
|
+
* path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
|
|
206
|
+
* given with the rest of the path when that is in it. */
|
|
207
|
+
export async function nearNames(path) {
|
|
208
|
+
let missing = resolve(path)
|
|
209
|
+
let folder = dirname(missing)
|
|
210
|
+
let rest = []
|
|
211
|
+
for (;;) {
|
|
212
|
+
if ((await stat(folder).catch(() => null))?.isDirectory()) break
|
|
213
|
+
if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
|
|
214
|
+
rest = [basename(missing), ...rest]
|
|
215
|
+
missing = folder
|
|
216
|
+
folder = dirname(folder)
|
|
217
|
+
}
|
|
218
|
+
const want = nearForm(basename(missing))
|
|
219
|
+
const names = await readdir(folder).catch(() => [])
|
|
220
|
+
const near = names
|
|
221
|
+
.map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
|
|
222
|
+
.filter((n) => n.apart <= NEAR_LETTERS)
|
|
223
|
+
.sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
|
|
224
|
+
.slice(0, NEAR_LISTED)
|
|
225
|
+
const paths = []
|
|
226
|
+
for (const { name } of near) {
|
|
227
|
+
const whole = join(folder, name, ...rest)
|
|
228
|
+
paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
|
|
229
|
+
}
|
|
230
|
+
return { folder, missing: basename(missing), near: paths }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** nearNames as a refusal's last sentence. */
|
|
234
|
+
async function nearNote(path) {
|
|
235
|
+
const { folder, missing, near } = await nearNames(path)
|
|
236
|
+
if (!folder) return ''
|
|
237
|
+
if (near.length) return ` Near it: ${near.join(', ')}.`
|
|
238
|
+
return ` ${folder} is there, with nothing in it named like ${missing}.`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The tool call's arguments as the command's input: a path read into the
|
|
242
|
+
* bytes it names. Returns the input, the input field the bytes go in, and
|
|
243
|
+
* the file read — a folder's one file, when the path named a folder. */
|
|
244
|
+
export async function inputFor(command, args, { cwd }) {
|
|
245
|
+
const input = { ...(args ?? {}) }
|
|
246
|
+
delete input.saveTo
|
|
247
|
+
const bytesKey = bytesProperty(command)
|
|
248
|
+
if (bytesKey) {
|
|
249
|
+
const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
|
|
250
|
+
delete input.path
|
|
251
|
+
let bytes
|
|
252
|
+
try {
|
|
253
|
+
bytes = new Uint8Array(await readFile(file))
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
|
|
256
|
+
throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
|
|
257
|
+
}
|
|
258
|
+
input[bytesKey] = bytes
|
|
259
|
+
if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
|
|
260
|
+
return { input, binaryKey: bytesKey, file }
|
|
261
|
+
}
|
|
262
|
+
if (command.returnsFile || command.returnsImage) delete input.path
|
|
263
|
+
if (command.returnsImage) delete input.inline
|
|
264
|
+
return { input, binaryKey: null, file: null }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Arguments with every `{ "$file": path }` in them — an object of that one
|
|
268
|
+
* key, at any depth — replaced by the JSON the file holds: a long list of
|
|
269
|
+
* stations a script wrote need not pass through the conversation. A
|
|
270
|
+
* relative path is taken from `cwd` — the folder the server takes
|
|
271
|
+
* relative paths from (mcp.js, pathBase). What a file holds is not looked
|
|
272
|
+
* into again. */
|
|
273
|
+
export async function withFiles(value, cwd) {
|
|
274
|
+
if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
|
|
275
|
+
if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
|
|
276
|
+
const keys = Object.keys(value)
|
|
277
|
+
if (keys.length === 1 && keys[0] === '$file') {
|
|
278
|
+
if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
|
|
279
|
+
const path = absolute(value.$file, cwd)
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
282
|
+
} catch (e) {
|
|
283
|
+
throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const out = {}
|
|
287
|
+
for (const key of keys) out[key] = await withFiles(value[key], cwd)
|
|
288
|
+
return out
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A file's kind by its extension, as scanruler_files lists it. */
|
|
292
|
+
const KINDS = {
|
|
293
|
+
mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
|
|
294
|
+
project: ['.scanruler'],
|
|
295
|
+
step: ['.step', '.stp'],
|
|
296
|
+
picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
|
|
297
|
+
drawing: ['.dxf', '.svg', '.pdf'],
|
|
298
|
+
text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
|
|
299
|
+
}
|
|
300
|
+
export const fileKind = (name) => {
|
|
301
|
+
const ext = extname(name).toLowerCase()
|
|
302
|
+
return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A picture a command handed back, as MCP content: the image, and the rest
|
|
306
|
+
* of the result as text — where the picture was written, if the call gave
|
|
307
|
+
* a path, in place of its bytes; written and not shown with `inline:
|
|
308
|
+
* false`. Null for a result with no picture in it. */
|
|
309
|
+
export async function imageContent(result, args, { cwd, outDir }) {
|
|
310
|
+
const file = result?.file
|
|
311
|
+
if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
|
|
312
|
+
const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
|
|
313
|
+
if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
|
|
314
|
+
return [
|
|
315
|
+
{ type: 'text', text: agentJson(saved) },
|
|
316
|
+
{ type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
|
|
317
|
+
]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** How many numbered names writeNew tries before it gives up. */
|
|
321
|
+
const NUMBERED_MOST = 10_000
|
|
322
|
+
|
|
323
|
+
/** `data` written to a new file in `folder` under `name` — or, a file of
|
|
324
|
+
* that name being there, as `stem-2.ext`, `stem-3.ext` …: each is made
|
|
325
|
+
* with `wx`, which refuses one that is there, so two written at once —
|
|
326
|
+
* pictures rendered side by side, answers spilled together — never take
|
|
327
|
+
* the same name, and nothing earlier is written over. Returns its path. */
|
|
328
|
+
export async function writeNew(folder, name, data) {
|
|
329
|
+
await mkdir(folder, { recursive: true })
|
|
330
|
+
const dot = name.lastIndexOf('.')
|
|
331
|
+
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
|
|
332
|
+
for (let n = 1; n <= NUMBERED_MOST; n++) {
|
|
333
|
+
const target = join(folder, n === 1 ? name : `${stem}-${n}${ext}`)
|
|
334
|
+
try {
|
|
335
|
+
await writeFile(target, data, { flag: 'wx' })
|
|
336
|
+
return target
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (e?.code !== 'EEXIST') throw e
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
throw { code: 'failed', message: `${folder} already holds ${NUMBERED_MOST} files named like ${name}: give a path to write it to.` }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A picture a batched call handed back, which a batch cannot show:
|
|
345
|
+
* written where the call asked, or into `folder` — the latest a scan or
|
|
346
|
+
* a project came from, which a sandboxed agent can open — else, or when
|
|
347
|
+
* that folder takes no file, into `outDir`; as saveFileResult writes any
|
|
348
|
+
* file, numbered past one already there, so a batch of renders, or a
|
|
349
|
+
* render from an earlier batch, is not written over. The result says
|
|
350
|
+
* where, in place of the bytes. */
|
|
351
|
+
export async function batchedPicture(result, args, { cwd, outDir, folder }) {
|
|
352
|
+
if (args?.path || !folder) return saveFileResult(result, args, { cwd, outDir })
|
|
353
|
+
try {
|
|
354
|
+
return await saveFileResult(result, args, { cwd, outDir: folder })
|
|
355
|
+
} catch {
|
|
356
|
+
return saveFileResult(result, args, { cwd, outDir })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Write a returned file where the call asked, or into `outDir`; the result
|
|
361
|
+
* then says where, in place of the bytes. A file path given is written,
|
|
362
|
+
* over one there; a folder — one there, or a path ending in a separator —
|
|
363
|
+
* or `outDir` takes the name ScanRuler gave the file, numbered past one
|
|
364
|
+
* already there (writeNew). */
|
|
365
|
+
export async function saveFileResult(result, args, { cwd, outDir }) {
|
|
366
|
+
const file = result?.file
|
|
367
|
+
if (!file || !(file.bytes instanceof Uint8Array)) return result
|
|
368
|
+
const name = basename(String(file.name ?? 'file'))
|
|
369
|
+
const given = args?.path ? absolute(String(args.path), cwd) : null
|
|
370
|
+
const folder = given === null ? outDir : /[\\/]$/.test(String(args.path)) || (await stat(given).catch(() => null))?.isDirectory() ? given : null
|
|
371
|
+
let target
|
|
372
|
+
if (folder !== null) target = await writeNew(folder, name, file.bytes)
|
|
373
|
+
else {
|
|
374
|
+
target = given
|
|
375
|
+
await mkdir(dirname(target), { recursive: true })
|
|
376
|
+
await writeFile(target, file.bytes)
|
|
377
|
+
}
|
|
378
|
+
return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
|
|
379
|
+
}
|