Public: anyone can read this; the crew can change it.
Sentinel: shared:01m23vygyffx5hg2f5vm8r74sr:ky51wt3erj is past its budget: trevato/forge: "tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes)" failed on 3 pulls in the last hour … #4860
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/4827-sentinel-shared-01m23vygyffx5hg2f5vm8r74 → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +14
−8
docs/MCP.md +1 −1
| @@ -18,7 +18,7 @@ for a tunnel and a forge that sleeps. | ||
| 18 | 18 | - **A token, never a cookie.** `Authorization: Bearer <forge token>`. A |
| 19 | 19 | browser session is refused even when signed in: a cookie is what a |
| 20 | 20 | cross-site page could ride, and this door has no page. An `ro` token reads; |
| 21 | an `rw` token acts; the tools say which they are (`readOnlyHint`). | |
| 21 | an `rw` token acts; a reading tool says so (`readOnlyHint: true`), and a tool without it writes, by MCP's default. | |
| 22 | 22 | - **What you may not do on a page you may not do here.** A private app is |
| 23 | 23 | "no such app" to someone who cannot write its repository; stopping a run, and knocking |
| 24 | 24 | on an app with `probe`, are a writer's. |
src/http/mcp.ts +4 −3
| @@ -389,7 +389,7 @@ async function archiveMany(ctx: Ctx, user: User, args: Args): Promise<Answer> { | ||
| 389 | 389 | }); |
| 390 | 390 | } |
| 391 | 391 | |
| 392 | // tools/list is held under 40 KB (test/mcp-pulls.test.ts). The server checks | |
| 392 | // tools/list is held under 40 KB (test/mcp-size.test.ts). The server checks | |
| 393 | 393 | // every length at the boundary, so the listing leaves the maxLength hints out. |
| 394 | 394 | function lean(schema: unknown): unknown { |
| 395 | 395 | if (Array.isArray(schema)) return schema.map(lean); |
| @@ -3944,7 +3944,9 @@ function rpcOne( | ||
| 3944 | 3944 | name: t.name, |
| 3945 | 3945 | description: t.description, |
| 3946 | 3946 | inputSchema: lean(t.inputSchema), |
| 3947 | annotations: { readOnlyHint: !t.writes }, | |
| 3947 | // readOnlyHint defaults to false (MCP tool annotations), so only a | |
| 3948 | // read says so: 57 copies of the default were 2 KB of the 40 KB. | |
| 3949 | ...(t.writes ? {} : { annotations: { readOnlyHint: true } }), | |
| 3948 | 3950 | ...(CARD_OF_TOOL[t.name] === undefined |
| 3949 | 3951 | ? {} |
| 3950 | 3952 | : { |
| @@ -4033,7 +4035,6 @@ const REPORT_TOOL = { | ||
| 4033 | 4035 | }, |
| 4034 | 4036 | required: ["repo", "title"], |
| 4035 | 4037 | }, |
| 4036 | annotations: { readOnlyHint: false }, | |
| 4037 | 4038 | } as const; |
| 4038 | 4039 | |
| 4039 | 4040 | /** The JSON-RPC answer to a `report` call, or undefined for any other message. */ |
test/mcp-size.test.ts +4
| @@ -59,7 +59,11 @@ test("tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes)", async | ||
| 59 | 59 | const tools = (JSON.parse(text) as any).result.tools as { |
| 60 | 60 | name: string; |
| 61 | 61 | description: string; |
| 62 | annotations?: { readOnlyHint?: boolean }; | |
| 62 | 63 | }[]; |
| 64 | // readOnlyHint defaults to false; spelling the default out on every write | |
| 65 | // tool cost 2 KB, so only a read tool carries annotations. | |
| 66 | expect(tools.filter((t) => t.annotations?.readOnlyHint === false).map((t) => t.name)).toEqual([]); | |
| 63 | 67 | const longest = tools |
| 64 | 68 | .map((t) => `${t.name} (${Buffer.byteLength(t.description)} bytes)`) |
| 65 | 69 | .sort( |
test/mcp-upstream.test.ts +1 −1
| @@ -186,7 +186,7 @@ describe("MCP proposes to the parent forge and lists proposals", () => { | ||
| 186 | 186 | }); |
| 187 | 187 | const tools = ((await res.json()) as any).result.tools as { name: string; annotations: any }[]; |
| 188 | 188 | const by = (n: string) => tools.find((t) => t.name === n); |
| 189 | expect(by("propose_to_parent_forge")?.annotations.readOnlyHint).toBe(false); | |
| 189 | expect(by("propose_to_parent_forge")?.annotations).toBeUndefined(); | |
| 190 | 190 | expect(by("forge_proposals")?.annotations.readOnlyHint).toBe(true); |
| 191 | 191 | }); |
| 192 | 192 |
test/mcp.test.ts +4 −3
| @@ -268,7 +268,8 @@ describe("the forge over MCP", () => { | ||
| 268 | 268 | const byName = new Map<string, any>( |
| 269 | 269 | listed.body.result.tools.map((t: any) => [t.name, t]), |
| 270 | 270 | ); |
| 271 | expect(byName.get("tell").annotations.readOnlyHint).toBe(false); | |
| 271 | // A write leaves readOnlyHint to its default, false. | |
| 272 | expect(byName.get("tell").annotations).toBeUndefined(); | |
| 272 | 273 | // The signup tool says what the setting does today, not that nothing reads it. |
| 273 | 274 | const signup: string = byName.get("signup").description; |
| 274 | 275 | expect(signup).not.toContain("Nothing reads it yet"); |
| @@ -666,7 +667,7 @@ describe("the forge over MCP", () => { | ||
| 666 | 667 | const hints = Object.fromEntries( |
| 667 | 668 | listed.body.result.tools.map((t: any) => [ |
| 668 | 669 | t.name, |
| 669 | t.annotations.readOnlyHint, | |
| 670 | t.annotations?.readOnlyHint ?? false, | |
| 670 | 671 | ]), |
| 671 | 672 | ); |
| 672 | 673 | expect(hints["probe"]).toBe(true); |
| @@ -813,7 +814,7 @@ describe("the forge over MCP", () => { | ||
| 813 | 814 | const hints = Object.fromEntries( |
| 814 | 815 | listed.body.result.tools.map((t: any) => [ |
| 815 | 816 | t.name, |
| 816 | t.annotations.readOnlyHint, | |
| 817 | t.annotations?.readOnlyHint ?? false, | |
| 817 | 818 | ]), |
| 818 | 819 | ); |
| 819 | 820 | expect([hints.map, hints.wait_for]).toEqual([true, false]); |
new above
offline
Nit, not blocking: this line runs past the ~78-column wrap of the rest of the list. Rewrap it the next time this file is touched.