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

MCP `read_file` says whether a JSON, YAML or TOML file parses, and the line and column where it breaks #7359

Merged opened this

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

Merged

scout/forge:scout/7345-mcp-read-file-says-whether-a-json-yaml-o → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7345-mcp-read-file-says-whether-a-json-yaml-o was merged into main as 1831a34.

Review

Sign in to review this change.
5 files changed +156 −4
src/http/mcp/tools/read_file.ts +17 −3
@@ -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 { checkConfig, configKind, type ConfigKind } from "../../../text/config_check.ts";
56 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
67
78 export const tool: Tool = {
@@ -45,15 +46,28 @@ export const tool: Tool = {
4546 if (blob.kind === "too_large")
4647 return good({ type: "file", tooLarge: true, size: blob.size });
4748 const binary = blob.kind === "binary";
49 const content = binary ? "" : new TextDecoder().decode(blob.body);
50 const kind = binary ? null : configKind(rawPath);
51 const about = kind === null ? undefined : aboutConfig(kind, content);

This runs on every read of a .json/.yaml/.toml blob up to 5 MB, for any caller who can read the repo, and it runs synchronously in the request. Once checkConfig is bounded, also skip about (or its position) above a size cap, so that one read cannot stall the process.

4852 return good({
4953 type: "file",
5054 ref,
5155 path: rawPath,
5256 size: blob.size,
5357 encoding: binary ? "base64" : "utf-8",
54 content: binary
55 ? Buffer.from(blob.body).toString("base64")
56 : new TextDecoder().decode(blob.body),
58 content: binary ? Buffer.from(blob.body).toString("base64") : content,
59 ...(about ? { about } : {}),
5760 });
5861 },
5962 };
63
64 function aboutConfig(kind: ConfigKind, content: string) {
65 const check = checkConfig(kind, content);
66 if (check.ok) return { kind, parses: true };
67 return {
68 kind,
69 parses: false,
70 ...(check.line === undefined ? {} : { line: check.line, column: check.column }),
71 message: check.message,
72 };
73 }
src/text/config_check.ts added +74
@@ -0,0 +1,74 @@
1 export type ConfigKind = "json" | "yaml" | "toml";
2 export type ConfigCheck =
3 | { ok: true }
4 | { ok: false; line?: number; column?: number; message: string };
5
6 /** Which parser reads a path, by extension; null for any other file. */
7 export function configKind(path: string): ConfigKind | null {
8 const m = /\.(json|ya?ml|toml)$/i.exec(path);
9 if (!m) return null;
10 const ext = m[1]!.toLowerCase();
11 return ext === "json" ? "json" : ext === "toml" ? "toml" : "yaml";
12 }
13
14 const parsers: Record<ConfigKind, (text: string) => unknown> = {
15 json: (t) => JSON.parse(t),
16 yaml: (t) => Bun.YAML.parse(t),
17 toml: (t) => Bun.TOML.parse(t),
18 };
19
20 function messageOf(err: unknown): string {
21 const m = err instanceof Error ? err.message : String((err as { message?: unknown })?.message ?? err);
22 return m.split("\n")[0]!.slice(0, 200);
23 }
24
25 function failsWith(kind: ConfigKind, text: string, message: string): boolean {
26 try {
27 parsers[kind](text);
28 return false;
29 } catch (err) {
30 return messageOf(err) === message;
31 }
32 }
33
34 /** Past this size a broken file is reported without a position: finding it costs parses. */
35 export const LOCATE_MAX_CHARS = 64_000;
36
37 /** Smallest n in [lo, hi] with `fails(n)`, by bisection (about log2 parses); hi when none is seen. */
38 function firstFailing(lo: number, hi: number, fails: (n: number) => boolean): number {
39 while (lo < hi) {
outdated

High: this loop parses one prefix per line, and the loop at line 48 parses one prefix per character. That makes the cost O(lines × bytes) of synchronous parsing. I measured 131 s for a 400 KB broken JSON and 16 s for a 40 KB one-line file, against a 5 MB cap in read_file. Bound it: compute the offset in one pass, or cap the size and the number of parse attempts and leave line and column out beyond the cap. Add a test with a large broken file that would fail today.

40 const mid = (lo + hi) >> 1;
41 if (fails(mid)) hi = mid;
42 else lo = mid + 1;
43 }
44 return lo;
45 }
46
47 /** JSON and YAML errors carry no position of ours: bisect for the shortest prefix that fails the same way. */
48 function locate(kind: ConfigKind, text: string, message: string): { line: number; column: number } | null {
49 if (text.length > LOCATE_MAX_CHARS) return null;
50 const starts = [0];
51 for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
52 const lineEnd = (k: number) => (k < starts.length ? starts[k]! - 1 : text.length);
53 const line = firstFailing(1, starts.length, (k) => failsWith(kind, text.slice(0, lineEnd(k)), message));
54 const start = starts[line - 1]!;
55 const end = lineEnd(line);
56 const n = firstFailing(start + 1, Math.max(start + 1, end), (m) => failsWith(kind, text.slice(0, m), message));
57 return { line, column: n - start };
58 }
59
60 /** Whether `text` parses as `kind`; when not, the 1-based line and column where it breaks. */
61 export function checkConfig(kind: ConfigKind, text: string): ConfigCheck {
62 try {
63 parsers[kind](text);
64 return { ok: true };
65 } catch (err) {
66 const message = messageOf(err);
67 if (kind === "toml") {
68 const pos = (err as { position?: { line?: unknown; column?: unknown } }).position;
69 if (typeof pos?.line === "number" && typeof pos.column === "number")
70 return { ok: false, line: pos.line, column: pos.column, message };
71 }
72 return { ok: false, ...(locate(kind, text, message) ?? {}), message };
73 }
74 }
test/config-check.test.ts added +30
@@ -0,0 +1,30 @@
1 import { expect, test } from "bun:test";
2 import { checkConfig, LOCATE_MAX_CHARS } from "../src/text/config_check.ts";
3
4 test("a small broken JSON file is located by line and column", () => {
5 const r = checkConfig("json", '{\n "a": 1,\n "b": ]\n}');
6 expect(r.ok).toBe(false);
7 if (!r.ok) expect(r.line).toBe(3);
8 });
9
10 test("a large broken file answers quickly", () => {
11 const lines = Array.from({ length: 5000 }, (_, i) => ` "k${i}": ${i},`).join("\n");
12 const multi = `{\n${lines}\n "end": ]\n}`;
13 const t = performance.now();
14 const r = checkConfig("json", multi);
15 expect(r.ok).toBe(false);
16 const oneLine = `[${"1,".repeat(50_000)}]]`;
17 expect(oneLine.length).toBeGreaterThan(LOCATE_MAX_CHARS);
18 const r2 = checkConfig("json", oneLine);
19 expect(r2.ok).toBe(false);
20 if (!r2.ok) expect(r2.line).toBeUndefined();
21 expect(performance.now() - t).toBeLessThan(1000);
22 });
23
24 test("alias-heavy YAML does not stall", () => {
25 let s = "a0: &a0 [x, x]\n";
26 for (let i = 1; i < 20000; i++) s += `a${i}: &a${i} [*a${i - 1}, *a${i - 1}]\n`;
27 const t = performance.now();
28 checkConfig("yaml", s);
29 expect(performance.now() - t).toBeLessThan(1000);
30 });
test/mcp-read-file.test.ts +13 −1
@@ -60,7 +60,11 @@ beforeAll(async () => {
6060 const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "pub", visibility: "public" }));
6161 const priv = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "priv", visibility: "private" }));
6262 for (const r of [pub, priv])
63 ok(await commitToBranch(app, owner, r, { message: "seed", files: [{ path: "src/a.txt", content: "hello\n" }] }));
63 ok(await commitToBranch(app, owner, r, { message: "seed", files: [
64 { path: "src/a.txt", content: "hello\n" },
65 { path: "bad.json", content: '{\n "a": }' },
66 { path: "good.json", content: "{}" },
67 ] }));
6468 tok["owner"] = createToken(app.db, owner.id, "o", "ro").token;
6569 tok["stranger"] = createToken(app.db, stranger.id, "s", "ro").token;
6670 }, 30_000);
@@ -92,3 +96,11 @@ test("bad arguments are refused", async () => {
9296 expect((await read(t, { repo: "ada/pub", path: 7 })).error).toContain("invalid");
9397 expect((await read(t, { repo: "ada/pub", path: "nope.txt" })).error).toContain("not_found");
9498 });
99
100 test("a config file says whether it parses; other files carry no about", async () => {
101 const t = tok["owner"]!;
102 const bad = await read(t, { repo: "ada/pub", path: "bad.json" });
103 expect(bad.value.about).toMatchObject({ kind: "json", parses: false, line: 2 });
104 expect((await read(t, { repo: "ada/pub", path: "good.json" })).value.about).toEqual({ kind: "json", parses: true });
105 expect((await read(t, { repo: "ada/pub", path: "src/a.txt" })).value.about).toBeUndefined();
106 });
test/text-config-check.test.ts added +22
@@ -0,0 +1,22 @@
1 import { expect, test } from "bun:test";
2 import { checkConfig, configKind } from "../src/text/config_check.ts";
3
4 test("the kind follows the extension", () => {
5 expect(configKind("a/b.JSON")).toBe("json");
6 expect(configKind("x.yml")).toBe("yaml");
7 expect(configKind("x.yaml")).toBe("yaml");
8 expect(configKind("x.toml")).toBe("toml");
9 expect(configKind("x.ts")).toBeNull();
10 });
11
12 test("valid files parse", () => {
13 expect(checkConfig("json", '{"a":1}')).toEqual({ ok: true });
14 expect(checkConfig("yaml", "a: 1\nb: [1, 2]\n")).toEqual({ ok: true });
15 expect(checkConfig("toml", "a = 1\n")).toEqual({ ok: true });
16 });
17
18 test("a broken file names its line and column", () => {
19 expect(checkConfig("json", '{\n "a": 1,\n "b": }')).toMatchObject({ ok: false, line: 3, column: 7 });
20 expect(checkConfig("yaml", 'a: 1\nb: "x')).toMatchObject({ ok: false, line: 2 });
21 expect(checkConfig("toml", "a = 1\nb = \nc = 3")).toMatchObject({ ok: false, line: 3 });
22 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs