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

Merged opened this

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

Merged

torque/forge:torque/4827-sentinel-shared-01m23vygyffx5hg2f5vm8r74 → main 2 commits fork 1 thread · 1 unresolved

  • Merged. torque/4827-sentinel-shared-01m23vygyffx5hg2f5vm8r74 was merged into main as 91bfc88.

Review

Sign in to review this change.
5 files changed +14 −8
docs/MCP.md +1 −1
@@ -18,7 +18,7 @@ for a tunnel and a forge that sleeps.
1818 - **A token, never a cookie.** `Authorization: Bearer <forge token>`. A
1919 browser session is refused even when signed in: a cookie is what a
2020 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.

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.

2222 - **What you may not do on a page you may not do here.** A private app is
2323 "no such app" to someone who cannot write its repository; stopping a run, and knocking
2424 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> {
389389 });
390390 }
391391
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
393393 // every length at the boundary, so the listing leaves the maxLength hints out.
394394 function lean(schema: unknown): unknown {
395395 if (Array.isArray(schema)) return schema.map(lean);
@@ -3944,7 +3944,9 @@ function rpcOne(
39443944 name: t.name,
39453945 description: t.description,
39463946 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 } }),
39483950 ...(CARD_OF_TOOL[t.name] === undefined
39493951 ? {}
39503952 : {
@@ -4033,7 +4035,6 @@ const REPORT_TOOL = {
40334035 },
40344036 required: ["repo", "title"],
40354037 },
4036 annotations: { readOnlyHint: false },
40374038 } as const;
40384039
40394040 /** 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
5959 const tools = (JSON.parse(text) as any).result.tools as {
6060 name: string;
6161 description: string;
62 annotations?: { readOnlyHint?: boolean };
6263 }[];
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([]);
6367 const longest = tools
6468 .map((t) => `${t.name} (${Buffer.byteLength(t.description)} bytes)`)
6569 .sort(
test/mcp-upstream.test.ts +1 −1
@@ -186,7 +186,7 @@ describe("MCP proposes to the parent forge and lists proposals", () => {
186186 });
187187 const tools = ((await res.json()) as any).result.tools as { name: string; annotations: any }[];
188188 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();
190190 expect(by("forge_proposals")?.annotations.readOnlyHint).toBe(true);
191191 });
192192
test/mcp.test.ts +4 −3
@@ -268,7 +268,8 @@ describe("the forge over MCP", () => {
268268 const byName = new Map<string, any>(
269269 listed.body.result.tools.map((t: any) => [t.name, t]),
270270 );
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();
272273 // The signup tool says what the setting does today, not that nothing reads it.
273274 const signup: string = byName.get("signup").description;
274275 expect(signup).not.toContain("Nothing reads it yet");
@@ -666,7 +667,7 @@ describe("the forge over MCP", () => {
666667 const hints = Object.fromEntries(
667668 listed.body.result.tools.map((t: any) => [
668669 t.name,
669 t.annotations.readOnlyHint,
670 t.annotations?.readOnlyHint ?? false,
670671 ]),
671672 );
672673 expect(hints["probe"]).toBe(true);
@@ -813,7 +814,7 @@ describe("the forge over MCP", () => {
813814 const hints = Object.fromEntries(
814815 listed.body.result.tools.map((t: any) => [
815816 t.name,
816 t.annotations.readOnlyHint,
817 t.annotations?.readOnlyHint ?? false,
817818 ]),
818819 );
819820 expect([hints.map, hints.wait_for]).toEqual([true, false]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs