Public: anyone can read this; the crew can change it.
An agent lists a repository's build pins through an MCP builds tool, as GET /api/v1/repos/:owner/:name/builds does #9106
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9091-an-agent-lists-a-repository-s-build-pins → main 4 commits fork
5 files
changed +103
−0
docs/features/build-pins.md +1
| @@ -8,6 +8,7 @@ is trimmed to what main calls). | ||
| 8 | 8 | |
| 9 | 9 | - Page: `/<owner>/<name>/builds` lists pins; `/builds/new` is the form (Branches has a Pin button per branch and a Builds link); Preview serves a pinned commit (`build_preview`); Delete is behind `/-/confirm`. |
| 10 | 10 | - API: `GET/POST /api/v1/repos/:owner/:name/builds`, `PATCH`/`DELETE …/builds/:label` (rw scope, a person who writes; agents are refused). |
| 11 | - MCP: the read-only `builds` tool takes `{ repo }` and answers `{ items, next_cursor: null }`, the rows the API lists; a repository the caller cannot read is not found. Pinning, noting and deleting are not offered to agents. | |
| 11 | 12 | - Terminal: on a repository, the palette's `<owner>/<name> — Builds` (`src/tui/screens/builds.ts`) lists the pins to anyone who may read it; a person who writes (never an agent) presses `p` to pin (prefilled with the next label and the default branch's tip), `n` to note, `d` to delete. |
| 12 | 13 | - Notes are an append-only list on `refs/forge/notes`. `FORGE_NOTES_INTENT=<file>` copies each person's note once into that file under `## Iterator notes`. |
| 13 | 14 | - Left out: demos and displays, screenshots, the Iterations graph, the lineage on Branches. |
src/http/mcp/tools/builds.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import { listBuildPins } from "../../../git/builds.ts"; | |
| 2 | import { repoDir } from "../../../git/repos.ts"; | |
| 3 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "builds", | |
| 7 | doc: "`{ repo }`: the repository's build pins, `{ items, next_cursor: null }`, as GET `/api/v1/repos/:owner/:name/builds` lists them (label, commit, who pinned it, note, parent, source); read-only, pinning stays with people on the page, the API and the terminal", | |
| 8 | description: "A repo's build pins (`refs/builds/`): label, commit, pinned by, note.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { repo: REPO_ARG }, | |
| 12 | required: ["repo"], | |
| 13 | }, | |
| 14 | writes: false, | |
| 15 | async run(ctx, user, args) { | |
| 16 | const repo = repoArg(ctx, user, args); | |
| 17 | if (isAnswer(repo)) return repo; | |
| 18 | return good({ items: await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name)), next_cursor: null }); | |
| 19 | }, | |
| 20 | }; |
src/http/mcp/tools/index.ts +1
| @@ -34,6 +34,7 @@ export { tool as board_pins } from "./board_pins.ts"; | ||
| 34 | 34 | export { tool as board_write } from "./board_write.ts"; |
| 35 | 35 | export { tool as boards } from "./boards.ts"; |
| 36 | 36 | export { tool as branches } from "./branches.ts"; |
| 37 | export { tool as builds } from "./builds.ts"; | |
| 37 | 38 | export { tool as capacity } from "./capacity.ts"; |
| 38 | 39 | export { tool as claim_link } from "./claim_link.ts"; |
| 39 | 40 | export { tool as close } from "./close.ts"; |
test/mcp-builds.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** MCP `builds`: the same pins as GET …/builds, read-only, and a private repository is not found to a stranger. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { getRepo } from "../src/core/repos.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { promote } from "./helpers/members.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | const tok: Record<string, string> = {}; | |
| 22 | ||
| 23 | async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> { | |
| 24 | const res = await fetch(`${servers.url}/mcp`, { | |
| 25 | method: "POST", | |
| 26 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 27 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "builds", arguments: args } }), | |
| 28 | }); | |
| 29 | const body = (await res.json()) as any; | |
| 30 | const text: string = body.result.content[0].text; | |
| 31 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 32 | } | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-builds-mcp-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | const make = async (username: string) => { | |
| 39 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: true }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | return made.value; | |
| 42 | }; | |
| 43 | const ada = await make("ada"); | |
| 44 | const sam = await make("sam"); | |
| 45 | promote(app.db, sam); | |
| 46 | for (const [name, visibility] of [["game", "public"], ["vault", "private"]] as const) { | |
| 47 | const made = await createRepo(app, ada, { name, visibility }); | |
| 48 | if (!made.ok) throw new Error(String(made.error)); | |
| 49 | const c = await commitToBranch(app, ada, getRepo(app.db, "ada", name)!, { message: "one", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 50 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 51 | const pinned = await pinBuild(app, ada, getRepo(app.db, "ada", name)!, { label: "v1", oid: c.value.commitOid, note: "first", source: "branch main" }); | |
| 52 | if (!pinned.ok) throw new Error(String(pinned.error)); | |
| 53 | } | |
| 54 | tok["ada"] = createToken(app.db, ada.id, "rw", "rw").token; | |
| 55 | tok["sam"] = createToken(app.db, sam.id, "rw", "rw").token; | |
| 56 | }, 30_000); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | servers?.stop(); | |
| 60 | app?.close(); | |
| 61 | await rm(dataDir, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | test("the tool answers the rows the API route lists", async () => { | |
| 65 | const api = await fetch(`${servers.url}/api/v1/repos/ada/game/builds`, { headers: { authorization: `Bearer ${tok["sam"]}` } }); | |
| 66 | const expected = (await api.json()) as any; | |
| 67 | expect(expected.items).toHaveLength(1); | |
| 68 | expect(expected.items[0].label).toBe("v1"); | |
| 69 | expect(expected.items[0].pinnedBy).toBe("ada"); | |
| 70 | const got = await mcp(tok["sam"]!, { repo: "ada/game" }); | |
| 71 | expect(got.value).toEqual(expected); | |
| 72 | expect(got.value.next_cursor).toBeNull(); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a private repository is not found to a caller who cannot read it, and names no pin", async () => { | |
| 76 | const refused = await mcp(tok["sam"]!, { repo: "ada/vault" }); | |
| 77 | expect(refused.error).toContain("not_found"); | |
| 78 | expect(refused.error).not.toContain("v1"); | |
| 79 | expect((await mcp(tok["ada"]!, { repo: "ada/vault" })).value.items).toHaveLength(1); | |
| 80 | }); |
test/mcp.test.ts +1
| @@ -274,6 +274,7 @@ describe("the forge over MCP", () => { | ||
| 274 | 274 | "events", |
| 275 | 275 | "reports", |
| 276 | 276 | "branches", |
| 277 | "builds", | |
| 277 | 278 | "commits", |
| 278 | 279 | "waves", |
| 279 | 280 | "probe", |
new above
offline