Public: anyone can read this; the crew can change it.

The MCP answers a repository's chapters: the open one, what is done and left in it, and when it finishes at this pace #11761

Merged opened this

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

Merged

scout/forge:scout/11749-the-mcp-answers-a-repository-s-chapters → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/11749-the-mcp-answers-a-repository-s-chapters was merged into main as bc5243e.

Review

Sign in to review this change.
7 files changed +135 −10
docs/MCP.md +1 −1
@@ -176,7 +176,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
176176 | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 |
177177 | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 |
178178 | `releases` | — | Kept releases, newest first | 253 |
179 | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 223 |
179 | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 296 |
180180 | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 |
181181 | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 |
182182 | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 310 |
docs/features/chapters.md +2
@@ -11,4 +11,6 @@ A chapter is a named list of a repository's issues that is finished, tagged and
1111
1212 Doors: the page (`/chapters`, linked from `/queue`), the terminal (`src/tui/screens/chapters.ts`, the palette's "Chapters" while an app is open: `n` drafts, `o` opens, `s` ships, `p` patches, `enter` shows the list where `a` adds an issue and `x` takes one out) and the API (`GET`/`POST /api/v1/repos/:owner/:name/chapters`, `POST …/chapters/:chapter/:verb` with `open`, `add`, `remove`, `ship`, `patch`). Only a person who administers the repository acts; an agent is refused.
1313
14 The MCP answers too: the `repo` tool with `view: "chapters"` lists a repository's chapters (name, what done means, state); adding `chapter: "v1"` names that chapter's items (`done`, `building` or `left`), its `progress` (the same sentence the page and the terminal print, from `progressSentence`) and `finishesOn`. `chapter` without `view` is `invalid`; a repository the caller cannot read is refused as the page refuses it.
15
1416 A closed chapter's list is frozen, so `itemsOf` keeps it in process memory (at most 200, `forgetFrozenItems` drops them); an open chapter's list is one pass over the repository's pulls and one grouped read of its runs, however many items it holds, so the anonymous page and API read stay cheap.
src/core/chapters.ts +4
@@ -318,6 +318,10 @@ export function progressOf(chapter: Chapter, items: readonly Item[], now: Date):
318318 return { done, total, finishesOn: new Date(now.getTime() + left * 86_400_000).toISOString().slice(0, 10) };
319319 }
320320
321 /** The sentence the page, and the MCP, print under a chapter's name. */
322 export const progressSentence = (p: Progress): string =>
323 `${p.done} of ${p.total} done${p.finishesOn === null ? "" : `; at this pace it finishes ${p.finishesOn}`}.`;
324
321325 // ---- the crew's gate ----
322326
323327 /** Whether the repository has closed a chapter and opened none since: the crew rests. */
src/http/mcp/tools/repo.ts +20 −3
@@ -3,22 +3,39 @@ import { forkParentFor } from "../../../core/repos.ts";
33 import { audienceFor } from "../../../core/audience.ts";
44 import { upstreamFacts } from "../../../actions/upstream_follow.ts";
55 import { creditFacts } from "../../../actions/credit_facts.ts";
6 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
6 import { getChapter, itemsOf, listChapters, progressOf, progressSentence } from "../../../core/chapters.ts";
7 import { good, str, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
78
89 export const tool: Tool = {
910 name: "repo",
10 doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), `credit` (a mirror's upstream, licence, authors; `null` otherwise), the same as `GET /api/v1/repos/:owner/:name`",
11 doc: "`{ repo }`: one repository's description, visibility, autonomy, `audience { level, who, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), `credit` (a mirror's upstream, licence, authors; `null` otherwise), the same as `GET /api/v1/repos/:owner/:name`; `view: \"chapters\"` lists its chapters with state, and with `chapter` names one's items (`done|building|left`) and `progress` (the page's sentence)",
1112 description:
1213 "One repository you can read, as GET /api/v1/repos/:owner/:name.",
1314 inputSchema: {
1415 type: "object",
15 properties: { repo: REPO_ARG },
16 properties: { repo: REPO_ARG, view: { type: "string", enum: ["chapters"] }, chapter: { type: "string" } },
1617 required: ["repo"],
1718 },
1819 writes: false,
1920 async run(ctx, user, args) {
2021 const repo = repoArg(ctx, user, args);
2122 if (isAnswer(repo)) return repo;
23 if (args["view"] === undefined && args["chapter"] !== undefined) return bad("invalid", "`chapter` needs `view: chapters`");
outdated

chapter given without view is silently ignored and returns the plain repo answer. Return invalid ("chapter needs view: chapters") and test it.

24 if (args["view"] !== undefined) {
25 if (args["view"] !== "chapters") return bad("invalid", "`view` is \"chapters\"");
26 const now = new Date();
27 const name = str(args["chapter"]);
28 if (name === "")
29 return good({ chapters: listChapters(ctx.app.db, repo.id).map((c) => ({ name: c.name, means: c.means, state: c.state })) });
30 const c = getChapter(ctx.app.db, repo.id, name);
31 if (c === undefined) return bad("not_found", "no such chapter");
32 const items = itemsOf(ctx.app.db, c);
33 const p = progressOf(c, items, now);
34 return good({
35 name: c.name, means: c.means, state: c.state, progress: progressSentence(p), finishesOn: p.finishesOn,
36 items: items.map((i) => ({ number: i.number, title: i.title, status: i.status, doneAt: i.doneAt })),
37 });
38 }
2239 return good({
2340 repo: `${repo.ownerName}/${repo.name}`,
2441 description: repo.description,
src/tui/screens/chapters.ts +4 −4
@@ -9,7 +9,7 @@
99 */
1010
1111 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
12 import { getChapter, itemsOf, listChapters, progressOf, type Chapter, type Item } from "../../core/chapters.ts";
12 import { getChapter, itemsOf, listChapters, progressOf, progressSentence, type Chapter, type Item } from "../../core/chapters.ts";
1313 import { canAdmin, type Repo } from "../../core/repos.ts";
1414 import type { Ctx, Screen } from "../app/app.ts";
1515 import type { Command } from "../app/palette.ts";
@@ -25,8 +25,8 @@ const isChapterEvent = (kind: string): boolean => kind.startsWith("chapter.") ||
2525 /** The words under a chapter's name: what done means, then how far it is. */
2626 function chapterRow(f: Session, c: Chapter): ListRow {
2727 const p = progressOf(c, itemsOf(f.app.db, c), new Date());
28 const progress = c.state === "closed" ? "finished" : `${p.done} of ${p.total} done`;
29 return { title: `${c.name} — ${c.state}`, note: c.means === "" ? progress : `${c.means} (${progress})` };
28 const progress = c.state === "closed" ? "finished" : progressSentence(p);
29 return { title: `${c.name} — ${c.state}`, note: c.means === "" ? progress : `${c.means} ${progress}` };
3030 }
3131
3232 function itemRow(i: Item): ListRow {
@@ -59,7 +59,7 @@ export function chapterScreen(f: Session, ctx: Ctx, repo: Repo, name: string): S
5959 head!.x,
6060 head!.y,
6161 truncate(
62 chapter === undefined ? "No such chapter." : `${chapter.state}: ${p!.done} of ${p!.total} done${p!.finishesOn === null ? "" : `, finishes ${p!.finishesOn}`}`,
62 chapter === undefined ? "No such chapter." : `${chapter.state}: ${progressSentence(p!)}`,
6363 head!.w,
6464 ),
6565 style(c.mutedFg, c.bg),
src/web/pages/chapters.ts +2 −2
@@ -7,7 +7,7 @@
77 */
88
99 import { CHAPTER_WORDS, close, draft, fill, open, patch } from "../../actions/chapters.ts";
10 import { getChapter, itemsOf, listChapters, progressOf, type Chapter, type Item } from "../../core/chapters.ts";
10 import { getChapter, itemsOf, listChapters, progressOf, progressSentence, type Chapter, type Item } from "../../core/chapters.ts";
1111 import { canAdmin, type Repo } from "../../core/repos.ts";
1212 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1313 import type { Ctx } from "../../http/context.ts";
@@ -113,7 +113,7 @@ export function chapterPage(ctx: Ctx): Response {
113113 const left = items.filter((i) => i.status === "left");
114114 const done = items.filter((i) => i.status === "done");
115115 main = html`<p>${c.means}</p>
116 <p data-progress>${p.done} of ${p.total} done${p.finishesOn === null ? "" : `; at this pace it finishes ${p.finishesOn}`}.</p>
116 <p data-progress>${progressSentence(p)}</p>
117117 ${items.length === 0 ? html`<p>No issues in this chapter yet.</p>` : null}
118118 <ul class="list setting-list">${[...done, ...building, ...left].map((i) => itemRow(repo, i, may, c))}</ul>
119119 ${may
test/mcp-chapters.test.ts added +102
@@ -0,0 +1,102 @@
1 /** `repo` with `view: "chapters"`: the page's states and progress sentence, and the page's refusal. */
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 { createRepo } from "../src/actions/repos.ts";
7 import { draft, fill, open } from "../src/actions/chapters.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { getChapter, itemsOf, progressOf, progressSentence } from "../src/core/chapters.ts";
11 import { getRepo } from "../src/core/repos.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let alice: string;
20 let bob: string;
21 let n = 1;
22
23 const call = async (token: string, args: unknown) => {
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: n++, method: "tools/call", params: { name: "repo", arguments: args } }),
28 });
29 const body: any = await res.json();
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-mcp-chapters-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const mk = async (username: string, first = false) => {
39 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
40 if (!u.ok) throw new Error(u.error);
41 return u.value;
42 };
43 const a = await mk("alice", true);
44 const b = await mk("bob");
45 alice = createToken(app.db, a.id, "a", "admin").token;
46 bob = createToken(app.db, b.id, "b", "admin").token;
47 for (const [name, visibility] of [["pub", "public"], ["priv", "private"]] as const) {
48 const made = await createRepo(app, a, { name, visibility });
49 if (!made.ok) throw new Error(String(made.error));
50 const repo = getRepo(app.db, "alice", name)!;
51 const nums: number[] = [];
52 for (const t of ["one", "two", "three"]) {
53 const i = createIssue(app.db, { repoId: repo.id, authorId: a.id, title: t, body: "" });
54 if (!i.ok) throw new Error("issue");
55 nums.push(i.value.number);
56 }
57 expect(draft(app, a, repo, { name: "v1", means: "It works." }).ok).toBe(true);
58 for (const num of nums) expect(fill(app, a, repo, "v1", num, true).ok).toBe(true);
59 expect(open(app, a, repo, "v1").ok).toBe(true);
60 const c = getChapter(app.db, repo.id, "v1")!;
61 const [first, second] = itemsOf(app.db, c);
62 // One done (closed by a merged, proven pull), one building (merged, not proven), one left.
63 for (const [issue, proven, pn] of [[first!, true, 900], [second!, false, 901]] as const) {
64 app.db.query("UPDATE issue SET state = 'closed', closed_at = '2026-11-10T00:00:00.000Z' WHERE id = ?1").run(issue.issueId);
65 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, 'pull', 'p', ?4, ?5, 'closed')`).run(`pl${pn}${name}`, repo.id, pn, `Closes #${issue.number}`, a.id);
66 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?1, 'main', ?2, 'a', 'b', '2026-11-10T00:00:00.000Z')`).run(`pl${pn}${name}`, `b${pn}`);
67 if (proven) app.db.query(`INSERT INTO release (sha, repo_id, pull_number, ok, line, created_at) VALUES ('abc', ?1, ?2, 1, 'ok', '2026-11-11T08:00:00.000Z')`).run(repo.id, pn);
68 }
69 }
70 }, 30_000);
71
72 afterAll(async () => {
73 servers.stop();
74 app.close();
75 await rm(dataDir, { recursive: true, force: true });
76 });
77
78 test("the list names each chapter with its state", async () => {
79 const r = await call(alice, { repo: "alice/pub", view: "chapters" });
80 expect(r.value.chapters).toEqual([{ name: "v1", means: "It works.", state: "open" }]);
81 });
82
83 test("a named chapter answers done, building, left and the page's sentence", async () => {
84 const r = await call(alice, { repo: "alice/pub", view: "chapters", chapter: "v1" });
85 expect(r.value.items.map((i: any) => i.status).sort()).toEqual(["building", "done", "left"]);
86 const repo = getRepo(app.db, "alice", "pub")!;
87 const c = getChapter(app.db, repo.id, "v1")!;
88 expect(r.value.progress).toBe(progressSentence(progressOf(c, itemsOf(app.db, c), new Date())));
89 expect(r.value.progress).toStartWith("1 of 3 done");
90 const page = await (await fetch(`${servers.url}/alice/pub/chapters/v1`, { headers: { authorization: `Bearer ${alice}` } })).text();
91 expect(page).toContain(r.value.progress);
92 });
93
94 test("a private repository answers a stranger as the page does, and an unknown chapter is not found", async () => {
95 const r = await call(bob, { repo: "alice/priv", view: "chapters" });
96 expect(r.error).toContain("no such app or repository");
97 expect((await fetch(`${servers.url}/alice/priv/chapters`, { headers: { authorization: `Bearer ${bob}` } })).status).toBe(404);
98 expect((await call(alice, { repo: "alice/pub", chapter: "v1" })).error).toContain("`chapter` needs `view: chapters`");
99 expect((await call(alice, { repo: "alice/priv", view: "chapters" })).value.chapters.length).toBe(1);
100 expect((await call(alice, { repo: "alice/pub", view: "chapters", chapter: "nope" })).error).toContain("no such chapter");
101 expect((await call(alice, { repo: "alice/pub", view: "x" })).error).toContain("chapters");
102 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs