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

Merged opened this

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

Merged

scout/forge:scout/9126-an-agent-reads-a-fork-family-s-iteration → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/9126-an-agent-reads-a-fork-family-s-iteration was merged into main as 5fef011.

Review

Sign in to review this change.
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
1919 ## Over the API
2020
2121 `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
9898 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
9999 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
100100 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
101 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
102101 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
103102 export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts";
104103 export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts";
@@ -121,6 +120,7 @@ export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1R
121120 export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts";
122121 export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts";
123122 export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts";
123 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
124124 export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts";
125125 export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts";
126126 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";
7676 export { tool as issue } from "./issue.ts";
7777 export { tool as issue_teams } from "./issue_teams.ts";
7878 export { tool as issues } from "./issues.ts";
79 export { tool as iterations } from "./iterations.ts";
7980 export { tool as keep_plan } from "./keep_plan.ts";
8081 export { tool as labels } from "./labels.ts";
8182 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";

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.

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", () => {
244244 "secrets",
245245 "issue_teams",
246246 "issues",
247 "iterations",
247248 "keep_plan",
248249 "orgs",
249250 "now",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs