Public: anyone can read this; the crew can change it.
An agent reads a fork family's iterations through an MCP iterations tool, as GET /api/v1/repos/:owner/:name/iterations does #9151
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9126-an-agent-reads-a-fork-family-s-iteration → main 4 commits fork 1 thread · 1 unresolved
6 files
changed +110
−1
docs/features/iterations.md +4
| @@ -19,3 +19,7 @@ Open a repository, then the palette (`ctrl+k`) and **Iterations**. The family's | ||
| 19 | 19 | ## Over the API |
| 20 | 20 | |
| 21 | 21 | `GET /api/v1/repos/:owner/:name/iterations` answers `{ forks, lanes }` for the viewer: the same forks (each with its pins: label, sha as `oid`, date as `at`, first line of `note`, `parent`), and the same runs the page draws, at most 40 repositories, sharing the 5-second pins cache. A private repository you may not read is a 404; a private fork you may not read is absent; a family with no pin answers 200 with empty lists. |
| 22 | ||
| 23 | ## Over MCP | |
| 24 | ||
| 25 | The `iterations` tool takes `{ owner, name }` and answers the same `{ forks, lanes }` as the route, through the same module and for the same caller: `not_found` for a private repository the caller may not read, empty lists for a family with no pin. It is read-only. |
src/contract/openapi/routes/index.ts +1 −1
| @@ -98,7 +98,6 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam | ||
| 98 | 98 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 99 | 99 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 100 | 100 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; |
| 101 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; | |
| 102 | 101 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| 103 | 102 | export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts"; |
| 104 | 103 | export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts"; |
| @@ -121,6 +120,7 @@ export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1R | ||
| 121 | 120 | export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
| 122 | 121 | export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts"; |
| 123 | 122 | export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts"; |
| 123 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; | |
| 124 | 124 | export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts"; |
| 125 | 125 | export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts"; |
| 126 | 126 | export { route as getApiV1ReposOwnerNameMilestones } from "./getApiV1ReposOwnerNameMilestones.ts"; |
src/http/mcp/tools/index.ts +1
| @@ -76,6 +76,7 @@ export { tool as invites } from "./invites.ts"; | ||
| 76 | 76 | export { tool as issue } from "./issue.ts"; |
| 77 | 77 | export { tool as issue_teams } from "./issue_teams.ts"; |
| 78 | 78 | export { tool as issues } from "./issues.ts"; |
| 79 | export { tool as iterations } from "./iterations.ts"; | |
| 79 | 80 | export { tool as keep_plan } from "./keep_plan.ts"; |
| 80 | 81 | export { tool as labels } from "./labels.ts"; |
| 81 | 82 | export { tool as land_past_gate } from "./land_past_gate.ts"; |
src/http/mcp/tools/iterations.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import { iterationsOf, lanesOf } from "../../../core/iterations.ts"; | |
| 2 | import { good, type Tool, repoArg, isAnswer, str } from "../shared.ts"; | |
| 3 | ||
| 4 | export const tool: Tool = { | |
| 5 | name: "iterations", | |
| 6 | doc: "`{ owner, name }`: the repository's fork family of builds, `{ forks, lanes }`, as GET `/api/v1/repos/:owner/:name/iterations` answers it and the Iterations page draws it (`forks`: each `{ repo, name, forkOf, builds: [{ key, label, oid, at, note, parent, edge }] }`; `lanes`: the tree's runs in drawing order); at most 40 repositories, a private fork the caller may not read is absent, a family with no pin answers empty lists; read-only", | |
| 7 | description: "A repo's fork family of build pins and runs, as the Iterations page draws them.", | |
| 8 | inputSchema: { | |
| 9 | type: "object", | |
| 10 | properties: { owner: { type: "string" }, name: { type: "string" } }, | |
| 11 | required: ["owner", "name"], | |
| 12 | }, | |
| 13 | writes: false, | |
| 14 | async run(ctx, user, args) { | |
| 15 | const repo = repoArg(ctx, user, { repo: `${str(args["owner"])}/${str(args["name"])}` }); | |
| 16 | if (isAnswer(repo)) return repo; | |
| 17 | const forks = (await iterationsOf(ctx.app, user, repo)) ?? []; | |
| 18 | return good({ forks, lanes: lanesOf(forks) }); | |
| 19 | }, | |
| 20 | }; |
test/mcp-iterations.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** MCP `iterations`: the same family as GET …/iterations, a private repository is not found, a family with no pin is empty. */ | |
| 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: "iterations", 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-iterations-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, pin] of [["game", "public", true], ["vault", "private", true], ["bare", "public", false]] 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 | if (!pin) continue; | |
| 52 | const pinned = await pinBuild(app, ada, getRepo(app.db, "ada", name)!, { label: "v1", oid: c.value.commitOid, note: "first", source: "branch main" }); | |
| 53 | if (!pinned.ok) throw new Error(String(pinned.error)); | |
| 54 | } | |
| 55 | tok["ada"] = createToken(app.db, ada.id, "rw", "rw").token; | |
| 56 | tok["sam"] = createToken(app.db, sam.id, "rw", "rw").token; | |
| 57 | }, 30_000); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | servers?.stop(); | |
| 61 | app?.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the tool answers what the API route answers for the same caller", async () => { | |
| 66 | const api = await fetch(`${servers.url}/api/v1/repos/ada/game/iterations`, { headers: { authorization: `Bearer ${tok["sam"]}` } }); | |
| 67 | const expected = (await api.json()) as any; | |
| 68 | expect(expected.forks[0].builds[0].label).toBe("v1"); | |
| 69 | expect((await mcp(tok["sam"]!, { owner: "ada", name: "game" })).value).toEqual(expected); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a private repository is not found to a caller who cannot read it", async () => { | |
| 73 | const refused = await mcp(tok["sam"]!, { owner: "ada", name: "vault" }); | |
| 74 | expect(refused.error).toContain("not_found"); | |
| 75 | expect(refused.error).not.toContain("v1"); | |
| 76 | expect((await mcp(tok["ada"]!, { owner: "ada", name: "vault" })).value.forks).toHaveLength(1); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a family with no pins answers empty lists", async () => { | |
| 80 | const got = await mcp(tok["ada"]!, { owner: "ada", name: "bare" }); | |
| 81 | expect(got.value.lanes).toEqual([]); | |
| 82 | expect(got.value.forks.every((f: any) => f.builds.length === 0)).toBe(true); | |
| 83 | }); |
test/mcp.test.ts +1
| @@ -244,6 +244,7 @@ describe("the forge over MCP", () => { | ||
| 244 | 244 | "secrets", |
| 245 | 245 | "issue_teams", |
| 246 | 246 | "issues", |
| 247 | "iterations", | |
| 247 | 248 | "keep_plan", |
| 248 | 249 | "orgs", |
| 249 | 250 | "now", |
new above
offline
Fine as is. The how-to for this tool belongs in docs/features/iterations.md next to the API paragraph, which is the one change needed.