Public: anyone can read this; the crew can change it.
One table-driven test: every MCP tool that writes refuses a read-only token #11501
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11493-one-table-driven-test-every-mcp-tool-tha → main 1 commit fork
1 file
changed +186
−0
test/mcp-ro-refuses-writes.test.ts added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | /** | |
| 2 | * The read-only/read-write line on /mcp, held over every tool at once | |
| 3 | * (#11493), not by each tool's own test. | |
| 4 | * | |
| 5 | * One forge boots with an admin, a repository, and an `ro` token for the | |
| 6 | * admin (so the admin-only tools are listed too). `tools/list` is walked as | |
| 7 | * that token, and every listed tool is called: | |
| 8 | * | |
| 9 | * - without `readOnlyHint: true` (it writes), with arguments that reach its | |
| 10 | * write path as far as `plansOnly` reads them — `dryRun: false`, an | |
| 11 | * `action` that is not a list, every `readsWithout` key given — and the | |
| 12 | * answer must be the plain refusal, with no row written anywhere; | |
| 13 | * - with `readOnlyHint: true`, with no arguments and again with the repo, | |
| 14 | * and no table may gain or lose a row. | |
| 15 | * | |
| 16 | * A new tool that writes without saying so, or one whose `readsWhen` the | |
| 17 | * write arguments here do not get past, fails a test that names it. The | |
| 18 | * admin-scope tools are not listed to an `ro` token at all; the last test | |
| 19 | * holds that each is refused by name. | |
| 20 | */ | |
| 21 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 22 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 23 | import { tmpdir } from "node:os"; | |
| 24 | import { join } from "node:path"; | |
| 25 | import { createApp, type App } from "../src/app.ts"; | |
| 26 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 27 | import { createToken } from "../src/core/tokens.ts"; | |
| 28 | import { createUser } from "../src/core/users.ts"; | |
| 29 | import { plansOnly } from "../src/http/mcp/shared.ts"; | |
| 30 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 31 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 32 | import { listAt } from "./mcp-list.ts"; | |
| 33 | ||
| 34 | const REFUSAL = "forbidden: this token may read, not act"; | |
| 35 | const REPO = "alice/game"; | |
| 36 | /** | |
| 37 | * The forge's own traffic counts: every request adds to them, flushed by a | |
| 38 | * timer about a second later (`core/route_timing.ts`, `core/usage_count.ts`), | |
| 39 | * so whichever call is in flight then would be blamed for them. | |
| 40 | */ | |
| 41 | const TRAFFIC = new Set(["route_timing", "usage_count"]); | |
| 42 | ||
| 43 | /** Arguments that take a writing tool past every `plansOnly` door it has. */ | |
| 44 | function writeArgs(name: string): Record<string, unknown> { | |
| 45 | const tool = TOOLS.find((t) => t.name === name); | |
| 46 | const args: Record<string, unknown> = { repo: REPO, dryRun: false, apply: true, action: "write" }; | |
| 47 | for (const k of tool?.readsWithout === undefined ? [] : [tool.readsWithout].flat()) args[k] ??= "x"; | |
| 48 | return args; | |
| 49 | } | |
| 50 | ||
| 51 | describe("every MCP tool that writes refuses a read-only token", () => { | |
| 52 | let dataDir: string; | |
| 53 | let app: App; | |
| 54 | let servers: Servers; | |
| 55 | let ro: string; | |
| 56 | let listed: { name: string; annotations?: { readOnlyHint?: boolean } }[] = []; | |
| 57 | ||
| 58 | /** Rows in every table, by table: what a write would change. */ | |
| 59 | const rows = (): Record<string, number> => { | |
| 60 | const tables = app.db | |
| 61 | .query<{ name: string }, []>( | |
| 62 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", | |
| 63 | ) | |
| 64 | .all(); | |
| 65 | const out: Record<string, number> = {}; | |
| 66 | for (const { name } of tables) { | |
| 67 | if (TRAFFIC.has(name)) continue; | |
| 68 | try { | |
| 69 | out[name] = app.db.query<{ n: number }, []>(`SELECT count(*) AS n FROM "${name.replaceAll('"', '""')}"`).get()!.n; | |
| 70 | } catch { | |
| 71 | // A virtual table's shadow may not count on its own; its parent does. | |
| 72 | } | |
| 73 | } | |
| 74 | return out; | |
| 75 | }; | |
| 76 | ||
| 77 | /** The tables whose row count moved between two counts, named. */ | |
| 78 | const moved = (a: Record<string, number>, b: Record<string, number>): string[] => | |
| 79 | [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((t) => a[t] !== b[t]).map((t) => `${t} ${a[t] ?? 0}→${b[t] ?? 0}`); | |
| 80 | ||
| 81 | const call = async (name: string, args: unknown): Promise<{ isError: boolean; text: string }> => { | |
| 82 | const res = await fetch(`${servers.url}/mcp`, { | |
| 83 | method: "POST", | |
| 84 | headers: { | |
| 85 | "content-type": "application/json", | |
| 86 | accept: "application/json, text/event-stream", | |
| 87 | authorization: `Bearer ${ro}`, | |
| 88 | }, | |
| 89 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 90 | }); | |
| 91 | const body = (await res.json()) as any; | |
| 92 | if (body.error !== undefined) return { isError: true, text: String(body.error.message) }; | |
| 93 | return { isError: body.result.isError === true, text: String(body.result.content?.[0]?.text ?? "") }; | |
| 94 | }; | |
| 95 | ||
| 96 | beforeAll(async () => { | |
| 97 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-ro-")); | |
| 98 | app = createApp({ dataDir, port: 0 }); | |
| 99 | servers = await serve(app); | |
| 100 | const made = await createUser(app.db, { | |
| 101 | origin: "seed", | |
| 102 | trusted: true, | |
| 103 | username: "alice", | |
| 104 | email: "[email protected]", | |
| 105 | password: "a-good-passphrase", | |
| 106 | adminIfFirst: true, | |
| 107 | }); | |
| 108 | if (!made.ok) throw new Error(made.error); | |
| 109 | const alice = made.value; | |
| 110 | const repo = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 111 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 112 | await commitToBranch(app, alice, repo.value, { message: "seed", files: [{ path: "README.md", content: "# game\n" }] }); | |
| 113 | ro = createToken(app.db, alice.id, "ro", "ro").token; | |
| 114 | listed = (await listAt(servers.url, ro)).result.tools; | |
| 115 | }, 30_000); | |
| 116 | ||
| 117 | afterAll(async () => { | |
| 118 | servers.stop(); | |
| 119 | app.close(); | |
| 120 | await rm(dataDir, { recursive: true, force: true }); | |
| 121 | }); | |
| 122 | ||
| 123 | test("tools/list names the tools, and the writing ones are most of them", () => { | |
| 124 | expect(listed.length).toBeGreaterThan(20); | |
| 125 | expect(listed.filter((t) => t.annotations?.readOnlyHint !== true).length).toBeGreaterThan(10); | |
| 126 | }); | |
| 127 | ||
| 128 | test("the write arguments here get past every tool's own read door", () => { | |
| 129 | // A tool whose `readsWhen` reads these as a read would be called here | |
| 130 | // and never reach the floor: name it, so its row in writeArgs is added. | |
| 131 | const stuck = TOOLS.filter((t) => t.writes && plansOnly(t, writeArgs(t.name))).map((t) => t.name); | |
| 132 | expect(stuck).toEqual([]); | |
| 133 | }); | |
| 134 | ||
| 135 | test("every tool without readOnlyHint refuses the ro token and writes nothing", async () => { | |
| 136 | const open: string[] = []; | |
| 137 | for (const t of listed) { | |
| 138 | if (t.annotations?.readOnlyHint === true) continue; | |
| 139 | // `report` and `start` are the token-free tools: a stranger may call | |
| 140 | // them, so an `ro` token is no less than no token; fuzz-mcp holds them. | |
| 141 | if (t.name === "report" || t.name === "start") continue; | |
| 142 | const before = rows(); | |
| 143 | const got = await call(t.name, writeArgs(t.name)); | |
| 144 | const after = rows(); | |
| 145 | if (!got.isError || got.text !== REFUSAL) open.push(`${t.name}: ${got.text.slice(0, 120)}`); | |
| 146 | const changed = moved(before, after); | |
| 147 | if (changed.length > 0) open.push(`${t.name} wrote: ${changed.join(", ")}`); | |
| 148 | } | |
| 149 | expect(open).toEqual([]); | |
| 150 | }, 10_000); | |
| 151 | ||
| 152 | test("every readOnlyHint tool leaves every table's row count as it was", async () => { | |
| 153 | const wrote: string[] = []; | |
| 154 | for (const t of listed) { | |
| 155 | if (t.annotations?.readOnlyHint !== true) continue; | |
| 156 | for (const args of [{}, { repo: REPO }]) { | |
| 157 | const before = rows(); | |
| 158 | await call(t.name, args); | |
| 159 | const changed = moved(before, rows()); | |
| 160 | if (changed.length > 0) wrote.push(`${t.name}(${JSON.stringify(args)}) wrote: ${changed.join(", ")}`); | |
| 161 | } | |
| 162 | } | |
| 163 | expect(wrote).toEqual([]); | |
| 164 | }, 10_000); | |
| 165 | ||
| 166 | test("a readOnlyHint is said only of a tool that does not write", () => { | |
| 167 | const said = new Set(listed.filter((t) => t.annotations?.readOnlyHint === true).map((t) => t.name)); | |
| 168 | expect(TOOLS.filter((t) => t.writes && said.has(t.name)).map((t) => t.name)).toEqual([]); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the admin-scope tools are not listed to an ro token, and are refused by name", async () => { | |
| 172 | const names = new Set(listed.map((t) => t.name)); | |
| 173 | const hidden = TOOLS.filter((t) => t.needsAdminScope === true); | |
| 174 | expect(hidden.filter((t) => names.has(t.name)).map((t) => t.name)).toEqual([]); | |
| 175 | for (const t of hidden) { | |
| 176 | const before = rows(); | |
| 177 | const got = await call(t.name, writeArgs(t.name)); | |
| 178 | expect({ name: t.name, isError: got.isError, text: got.text }).toEqual({ | |
| 179 | name: t.name, | |
| 180 | isError: true, | |
| 181 | text: `no tool named ${t.name}`, | |
| 182 | }); | |
| 183 | expect(moved(before, rows())).toEqual([]); | |
| 184 | } | |
| 185 | }); | |
| 186 | }); |