Public: anyone can read this; the crew can change it.

MCP `read_file` describes an STL model in words: triangles, size in model units, ASCII or binary #7362

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7346-mcp-read-file-describes-an-stl-model-in → main 4 commits fork 3 threads · 3 unresolved

  • Merged. torque/7346-mcp-read-file-describes-an-stl-model-in was merged into main as 081b97f.

Review

Sign in to review this change.
5 files changed +365 −2
src/http/mcp/tools/read_file.ts +16 −2
@@ -2,6 +2,7 @@ import { repoDir } from "../../../git/repos.ts";
22 import { runGit } from "../../../git/env.ts";
33 import { isSafeRef, isSafeTreePath } from "../../../git/guard.ts";
44 import { blobForRender, listTree } from "../../../git/read.ts";
5 import { readStl, sayStl, stlSize, TOO_LARGE } from "../../../text/stl.ts";
56 import { checkConfig, configKind, type ConfigKind } from "../../../text/config_check.ts";
67 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
78
@@ -47,8 +48,7 @@ export const tool: Tool = {
4748 return good({ type: "file", tooLarge: true, size: blob.size });
4849 const binary = blob.kind === "binary";
outdated

This call runs on the event loop for any .stl up to 5 MB. Put the size gate here or in readStl, so the parser never sees an unbounded body.

4950 const content = binary ? "" : new TextDecoder().decode(blob.body);
50 const kind = binary ? null : configKind(rawPath);
51 const about = kind === null ? undefined : aboutConfig(kind, content);
51 const about = aboutFile(rawPath, blob.body, binary ? undefined : content);
5252 return good({
5353 type: "file",
5454 ref,
@@ -61,6 +61,20 @@ export const tool: Tool = {
6161 },
6262 };
6363
64 /** A file described in words beside its content; one branch per kind. */
65 function aboutFile(path: string, body: Uint8Array, text: string | undefined): Record<string, unknown> | undefined {
66 if (/\.stl$/i.test(path)) {
67 const stl = readStl(body);
68 if (!stl.ok && stl.error === TOO_LARGE) return { kind: "stl", says: `STL, ${TOO_LARGE}` };
69 if (!stl.ok) return { kind: "stl", says: `STL, unreadable: ${stl.error}` };
70 const { format, triangles } = stl.value;
71 return { kind: "stl", format, triangles, size: stlSize(stl.value), says: sayStl(stl.value) };
72 }
73 if (text === undefined) return undefined;
74 const kind = configKind(path);
75 return kind === null ? undefined : aboutConfig(kind, text);
76 }
77
6478 function aboutConfig(kind: ConfigKind, content: string) {
6579 const check = checkConfig(kind, content);
6680 if (check.ok) return { kind, parses: true };
src/text/stl.ts added +192
@@ -0,0 +1,192 @@
1 /**
2 * STL, read for its shape: whether it is ASCII or binary, how many triangles
3 * it holds, and the axis-aligned box around every vertex — in the file's own
4 * model units, which STL never names. Only the bounds are kept, never the
5 * mesh. A truncated or lying file is an Err naming why; nothing here throws.
6 *
7 * Binary (after the 3D Systems "StereoLithography Interface Specification",
8 * 1989): an 80-byte header, a little-endian uint32 triangle count at offset
9 * 80, then 50 bytes per triangle — a normal and three vertices as
10 * little-endian float32 (12 × 4 bytes) and a uint16 attribute count. So a
11 * file of n triangles is exactly 84 + 50·n bytes.
12 *
13 * ASCII: `solid name`, then per triangle `facet normal i j k` / `outer loop` /
14 * three `vertex x y z` / `endloop` / `endfacet`, closed by `endsolid`.
15 */
16 import { err, ok, type Result } from "../kernel/result.ts";
17
18 export type Vec3 = readonly [number, number, number];
19
20 export type Stl = {
21 readonly format: "ascii" | "binary";
22 readonly triangles: number;
23 readonly min: Vec3;
24 readonly max: Vec3;
25 };
26
27 const HEADER = 80;
28 const RECORD = 50;
29
30 /** Read an STL's format, triangle count and bounds from its bytes. */
31 export function readStl(bytes: Uint8Array): Result<Stl, string> {
32 const binary = bytes.length >= HEADER + 4 ? expectedBinaryLength(bytes) : undefined;
33 // A binary header may itself begin with "solid", so a length that matches
34 // the binary layout exactly wins over the ASCII keyword.
35 if (binary !== undefined && binary === bytes.length) return readBinary(bytes);
36 if (startsWithSolid(bytes)) {
outdated

Low: a binary file whose header starts with solid and whose length does not match comes here. It gets an ASCII error (truncated: no endsolid) that hides the real cause. When the ASCII read fails and binary !== undefined, return the binary length mismatch error instead, and add a test for it.

37 const read = readAscii(bytes);
38 // Not ASCII after all: a binary header that begins with "solid" and a
39 // length that does not fit says more as the binary mismatch.
40 if (read.ok || binary === undefined || looksAscii(bytes)) return read;
41 }
42 if (binary === undefined) return err("too short for an STL header");
43 const n = (binary - HEADER - 4) / RECORD;
44 return bytes.length < binary
45 ? err(`truncated: says ${n} triangles, holds ${bytes.length} of ${binary} bytes`)
46 : err(`says ${n} triangles but holds ${bytes.length} bytes, not ${binary}`);
47 }
48
49 /** One line: "STL (binary) · 12,340 triangles · 40.0 × 20.0 × 10.0". */
50 export function sayStl(stl: Stl): string {
51 const count = `${stl.triangles.toLocaleString("en-US")} triangle${stl.triangles === 1 ? "" : "s"}`;
52 const size = stlSize(stl).map((d) => d.toFixed(1)).join(" × ");
53 return `STL (${stl.format}) · ${count} · ${size}`;
54 }
55
56 /** The box's extent on each axis, max − min, in model units. */
57 export function stlSize(stl: Stl): Vec3 {
58 return [stl.max[0] - stl.min[0], stl.max[1] - stl.min[1], stl.max[2] - stl.min[2]];
59 }
60
61 function expectedBinaryLength(bytes: Uint8Array): number {
62 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
63 return HEADER + 4 + view.getUint32(HEADER, true) * RECORD;
64 }
65
66 function startsWithSolid(bytes: Uint8Array): boolean {
67 let i = 0;
68 while (i < bytes.length && (bytes[i] === 0x20 || bytes[i] === 0x09 || bytes[i] === 0x0a || bytes[i] === 0x0d)) i++;
69 const word = Buffer.from(bytes.subarray(i, i + 5)).toString("latin1");
70 return word.toLowerCase() === "solid";
71 }
72
73 /**
74 * Whether the four bytes where a binary count would sit are all text. A real
75 * count that is all text claims at least 0x09090909 (151 million) triangles,
76 * gigabytes no file here holds, so text there means the file is ASCII.
77 */
78 function looksAscii(bytes: Uint8Array): boolean {
79 for (let i = HEADER; i < HEADER + 4; i++) if (bytes[i]! < 0x09 || (bytes[i]! > 0x0d && bytes[i]! < 0x20)) return false;
80 return true;
81 }
82
83 class Box {
84 min = [Infinity, Infinity, Infinity];
85 max = [-Infinity, -Infinity, -Infinity];
86 add(x: number, y: number, z: number): void {
87 const p = [x, y, z];
88 for (let a = 0; a < 3; a++) {
89 if (p[a]! < this.min[a]!) this.min[a] = p[a]!;
90 if (p[a]! > this.max[a]!) this.max[a] = p[a]!;
91 }
92 }
93 done(format: Stl["format"], triangles: number): Result<Stl, string> {
94 if (triangles === 0) return err("holds no triangles");
95 return ok({
96 format,
97 triangles,
98 min: [this.min[0]!, this.min[1]!, this.min[2]!],
99 max: [this.max[0]!, this.max[1]!, this.max[2]!],
100 });
101 }
102 }
103
104 function readBinary(bytes: Uint8Array): Result<Stl, string> {
105 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
106 const n = view.getUint32(HEADER, true);
107 const box = new Box();
108 for (let t = 0; t < n; t++) {
109 // Skip the 12-byte normal; the three vertices follow.
110 const at = HEADER + 4 + t * RECORD + 12;
111 for (let v = 0; v < 3; v++) {
112 const o = at + v * 12;
113 const x = view.getFloat32(o, true);
114 const y = view.getFloat32(o + 4, true);
115 const z = view.getFloat32(o + 8, true);
outdated

High (denial of service): on a 5 MB ASCII body this line builds about a million strings, and the loop below runs a regex on each number. I measured about 300 ms of synchronous work per read_file call, and any read-only caller can repeat it. Before calling readAscii, cap the bytes it will describe (for example 1 MB) and return a 'too large to describe' result above the cap. Or scan the bytes in one pass without split or a regex per token. Add a test for the cap or for the time budget.

116 if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
117 return err(`triangle ${t + 1} has a vertex that is not a finite number`);
118 box.add(x, y, z);
119 }
120 }
121 return box.done("binary", n);
122 }
123
124 /** ASCII is described only up to this many bytes: one pass over 1 MB stays well under 50 ms on the one server thread. */
125 export const ASCII_LIMIT = 1_000_000;
126 export const TOO_LARGE = "too large to describe here";
127
128 const FLOAT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
129
130 function isSpace(c: number): boolean {
131 return c === 0x20 || (c >= 0x09 && c <= 0x0d);
132 }
133
134 /** Whether text[s, e) is `word` (lower case), ignoring case, without slicing. */
135 function isWord(text: string, s: number, e: number, word: string): boolean {
136 if (e - s !== word.length) return false;
137 for (let k = 0; k < word.length; k++) if ((text.charCodeAt(s + k) | 0x20) !== word.charCodeAt(k)) return false;
138 return true;
139 }
140
141 // One pass over the text by index: keywords are compared in place and only the
142 // three numbers of each vertex become strings, so the work is linear in bytes.
143 function readAscii(bytes: Uint8Array): Result<Stl, string> {
144 if (bytes.length > ASCII_LIMIT) return err(TOO_LARGE);
145 const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("latin1");
146 // The solid's name runs to the end of its line and may hold any word.
147 const firstBreak = text.search(/[\r\n]/);
148 if (firstBreak === -1) return err("truncated: an ASCII STL with no facets");
149 let at = firstBreak;
150 let s = 0;
151 /** Advance to the next token; false at the end. Sets s and at to its bounds. */
152 const next = (): boolean => {
153 while (at < text.length && isSpace(text.charCodeAt(at))) at++;
154 s = at;
155 while (at < text.length && !isSpace(text.charCodeAt(at))) at++;
156 return at > s;
157 };
158 const box = new Box();
159 let triangles = 0;
160 let inFacet = false;
161 let vertices = 0;
162 while (next()) {
163 if (isWord(text, s, at, "facet")) {
164 if (inFacet) return err(`facet ${triangles + 1} opens before the last one ends`);
165 inFacet = true;
166 vertices = 0;
167 triangles++;
168 } else if (isWord(text, s, at, "vertex")) {
169 if (!inFacet) return err("a vertex outside any facet");
170 const xyz: number[] = [];
171 for (let a = 0; a < 3; a++) {
172 if (!next()) return err(`truncated: facet ${triangles} ends mid-vertex`);
173 const word = text.slice(s, at);
174 if (!FLOAT.test(word)) return err(`facet ${triangles} has a vertex that is not three numbers`);
175 xyz.push(Number(word));
176 }
177 const [x, y, z] = xyz as [number, number, number];
178 if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
179 return err(`facet ${triangles} has a vertex that is not a finite number`);
180 box.add(x, y, z);
181 vertices++;
182 } else if (isWord(text, s, at, "endfacet")) {
183 if (!inFacet) return err("endfacet without a facet");
184 if (vertices !== 3) return err(`facet ${triangles} has ${vertices} vertices, not 3`);
185 inFacet = false;
186 } else if (isWord(text, s, at, "endsolid")) {
187 if (inFacet) return err(`truncated: facet ${triangles} is never closed`);
188 return box.done("ascii", triangles);
189 }
190 }
191 return err(inFacet ? `truncated: facet ${triangles} is never closed` : "truncated: no endsolid");
192 }
test/helpers/stl.ts added +33
@@ -0,0 +1,33 @@
1 /** A box from the origin to (x, y, z) as STL: 12 triangles, two per face. */
2 type V = [number, number, number];
3
4 function boxTriangles(x: number, y: number, z: number): V[][] {
5 const p = (i: number): V => [i & 1 ? x : 0, i & 2 ? y : 0, i & 4 ? z : 0];
6 const faces = [
7 [0, 1, 3, 2], [4, 6, 7, 5], [0, 4, 5, 1], [2, 3, 7, 6], [0, 2, 6, 4], [1, 5, 7, 3],
8 ];
9 return faces.flatMap(([a, b, c, d]) => [
10 [p(a!), p(b!), p(c!)],
11 [p(a!), p(c!), p(d!)],
12 ]);
13 }
14
15 export function binaryBox(x: number, y: number, z: number, header = "box"): Uint8Array {
16 const tris = boxTriangles(x, y, z);
17 const out = new Uint8Array(84 + 50 * tris.length);
18 out.set(new TextEncoder().encode(header.slice(0, 80)));
19 const view = new DataView(out.buffer);
20 view.setUint32(80, tris.length, true);
21 tris.forEach((tri, t) => {
22 tri.forEach((v, i) => v.forEach((c, a) => view.setFloat32(84 + t * 50 + 12 + i * 12 + a * 4, c, true)));
23 });
24 return out;
25 }
26
27 export function asciiBox(x: number, y: number, z: number): string {
28 const facets = boxTriangles(x, y, z).map(
29 (tri) =>
30 ` facet normal 0 0 0\n outer loop\n${tri.map((v) => ` vertex ${v.join(" ")}\n`).join("")} endloop\n endfacet\n`,
31 );
32 return `solid box\n${facets.join("")}endsolid box\n`;
33 }
test/mcp-read-file.test.ts +33
@@ -10,6 +10,7 @@ import { createToken } from "../src/core/tokens.ts";
1010 import { createUser } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
13 import { asciiBox, binaryBox } from "./helpers/stl.ts";
1314
1415 quietLogs();
1516
@@ -65,6 +66,17 @@ beforeAll(async () => {
6566 { path: "bad.json", content: '{\n "a": }' },
6667 { path: "good.json", content: "{}" },
6768 ] }));
69 ok(
70 await commitToBranch(app, owner, pub, {
71 message: "models",
72 files: [
73 { path: "cube.stl", content: binaryBox(40, 20, 10) },
74 { path: "cube-ascii.STL", content: asciiBox(40, 20, 10) },
75 { path: "broken.stl", content: binaryBox(1, 1, 1).subarray(0, 100) },
76 { path: "huge.stl", content: asciiBox(1, 1, 1).repeat(1_000_000 / 1400 + 1) },
77 ],
78 }),
79 );
6880 tok["owner"] = createToken(app.db, owner.id, "o", "ro").token;
6981 tok["stranger"] = createToken(app.db, stranger.id, "s", "ro").token;
7082 }, 30_000);
@@ -97,6 +109,27 @@ test("bad arguments are refused", async () => {
97109 expect((await read(t, { repo: "ada/pub", path: "nope.txt" })).error).toContain("not_found");
98110 });
99111
112 test("an STL file is described in words", async () => {
113 const t = tok["stranger"]!;
114 const bin = (await read(t, { repo: "ada/pub", path: "cube.stl" })).value;
115 expect(bin.encoding).toBe("base64");
116 expect(bin.about).toEqual({
117 kind: "stl",
118 format: "binary",
119 triangles: 12,
120 size: [40, 20, 10],
121 says: "STL (binary) · 12 triangles · 40.0 × 20.0 × 10.0",
122 });
123 const txt = (await read(t, { repo: "ada/pub", path: "cube-ascii.STL" })).value;
124 expect(txt.about.says).toBe("STL (ascii) · 12 triangles · 40.0 × 20.0 × 10.0");
125 const broken = (await read(t, { repo: "ada/pub", path: "broken.stl" })).value;
126 expect(broken.about.kind).toBe("stl");
127 expect(broken.about.says).toContain("truncated");
128 const huge = (await read(t, { repo: "ada/pub", path: "huge.stl" })).value;
129 expect(huge.size).toBeGreaterThan(1_000_000);
130 expect(huge.about).toEqual({ kind: "stl", says: "STL, too large to describe here" });
131 });
132
100133 test("a config file says whether it parses; other files carry no about", async () => {
101134 const t = tok["owner"]!;
102135 const bad = await read(t, { repo: "ada/pub", path: "bad.json" });
test/text-stl.test.ts added +91
@@ -0,0 +1,91 @@
1 /** STL: format, triangle count and bounds from ASCII and binary bytes; a truncated or lying file is an Err, never a throw. */
2 import { expect, test } from "bun:test";
3 import { ASCII_LIMIT, readStl, sayStl, stlSize, TOO_LARGE } from "../src/text/stl.ts";
4 import { asciiBox, binaryBox } from "./helpers/stl.ts";
5 import { expectBudget } from "./helpers/budget.ts";
6
7 const ascii = (s: string) => new TextEncoder().encode(s);
8
9 test("a binary box: 12 triangles, bounds from the origin to its corner", () => {
10 const r = readStl(binaryBox(40, 20, 10));
11 expect(r).toEqual({ ok: true, value: { format: "binary", triangles: 12, min: [0, 0, 0], max: [40, 20, 10] } });
12 if (r.ok) expect(sayStl(r.value)).toBe("STL (binary) · 12 triangles · 40.0 × 20.0 × 10.0");
13 });
14
15 test("a binary header that begins with 'solid' is still binary when the length fits", () => {
16 const r = readStl(binaryBox(1, 2, 3, "solid exported by a CAD tool"));
17 expect(r.ok && r.value.format).toBe("binary");
18 });
19
20 test("an ASCII box: 12 triangles, the same bounds", () => {
21 const r = readStl(ascii(asciiBox(40, 20, 10)));
22 expect(r).toEqual({ ok: true, value: { format: "ascii", triangles: 12, min: [0, 0, 0], max: [40, 20, 10] } });
23 if (r.ok) expect(sayStl(r.value)).toBe("STL (ascii) · 12 triangles · 40.0 × 20.0 × 10.0");
24 });
25
26 test("size is max − min per axis, and counts are grouped", () => {
27 const stl = { format: "binary" as const, triangles: 12340, min: [-5, 1, 0] as const, max: [35, 21, 10] as const };
28 expect(stlSize(stl)).toEqual([40, 20, 10]);
29 expect(sayStl(stl)).toBe("STL (binary) · 12,340 triangles · 40.0 × 20.0 × 10.0");
30 expect(sayStl({ ...stl, triangles: 1 })).toContain("· 1 triangle ·");
31 });
32
33 test("a truncated or lying file is an error naming why", () => {
34 const full = binaryBox(1, 1, 1);
35 const cut = readStl(full.subarray(0, full.length - 7));
36 expect(cut.ok).toBe(false);
37 if (!cut.ok) expect(cut.error).toContain("truncated");
38 const lying = new Uint8Array(full);
39 new DataView(lying.buffer).setUint32(80, 4_000_000_000, true);
40 expect(readStl(lying).ok).toBe(false);
41 expect(readStl(new Uint8Array(10))).toEqual({ ok: false, error: "too short for an STL header" });
42 const text = asciiBox(1, 1, 1);
43 const half = readStl(ascii(text.slice(0, text.length / 2)));
44 expect(half.ok).toBe(false);
45 if (!half.ok) expect(half.error).toContain("truncated");
46 expect(readStl(ascii("solid x\nfacet normal 0 0 0\nouter loop\nvertex 1 2 nope\n")).ok).toBe(false);
47 expect(readStl(ascii("solid empty\nendsolid empty\n"))).toEqual({ ok: false, error: "holds no triangles" });
48 const nan = binaryBox(1, 1, 1);
49 new DataView(nan.buffer).setFloat32(84 + 12, NaN, true);
50 expect(readStl(nan).ok).toBe(false);
51 });
52
53 test("an ASCII body over the limit is too large to describe, and one at the limit reads fast", () => {
54 const facet = "facet normal 0 0 0\nouter loop\nvertex 1 2 3\nvertex 4 5 6\nvertex 7 8 9\nendloop\nendfacet\n";
55 const fill = (bytes: number) => {
56 const head = "solid big\n";
57 const tail = "endsolid big\n";
58 const n = Math.floor((bytes - head.length - tail.length) / facet.length);
59 const body = head + facet.repeat(n) + tail;
60 return { n, bytes: ascii(body + " ".repeat(bytes - body.length)) };
61 };
62 const at = fill(ASCII_LIMIT);
63 expect(at.bytes.length).toBe(ASCII_LIMIT);
64 const best = (bytes: Uint8Array) => {
65 let ms = Infinity;
66 for (let i = 0; i < 3; i++) {
67 const start = performance.now();
68 readStl(bytes);
69 ms = Math.min(ms, performance.now() - start);
70 }
71 return ms;
72 };
73 // The baseline is a binary model of the same byte size read in this run, so load slows both alike.
74 const bin = new Uint8Array(84 + 50 * Math.floor((ASCII_LIMIT - 84) / 50));
75 new DataView(bin.buffer).setUint32(80, (bin.length - 84) / 50, true);
76 expect(readStl(at.bytes)).toEqual({ ok: true, value: { format: "ascii", triangles: at.n, min: [1, 2, 3], max: [7, 8, 9] } });
77 expectBudget(best(at.bytes), { ms: 50, against: best(bin) });
78 expect(readStl(fill(ASCII_LIMIT + 1).bytes)).toEqual({ ok: false, error: TOO_LARGE });
79 expect(readStl(fill(5_000_000).bytes)).toEqual({ ok: false, error: TOO_LARGE });
80 // Junk tokens are no slower to refuse: the limit is checked before any scan.
81 expect(readStl(ascii("solid a\n" + "x ".repeat(2_500_000)))).toEqual({ ok: false, error: TOO_LARGE });
82 });
83
84 test("a binary header that begins with 'solid' and a length that does not fit names the length, not ASCII", () => {
85 const full = binaryBox(1, 1, 1, "solid x");
86 const cut = readStl(full.subarray(0, full.length - 7));
87 expect(cut).toEqual({ ok: false, error: `truncated: says 12 triangles, holds ${full.length - 7} of ${full.length} bytes` });
88 const long = new Uint8Array(full.length + 3);
89 long.set(full);
90 expect(readStl(long)).toEqual({ ok: false, error: `says 12 triangles but holds ${full.length + 3} bytes, not ${full.length}` });
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs