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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11749-the-mcp-answers-a-repository-s-chapters → main 3 commits fork 1 thread · 1 unresolved
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`; | ||
| 176 | 176 | | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 | |
| 177 | 177 | | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 | |
| 178 | 178 | | `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 | | |
| 180 | 180 | | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 | |
| 181 | 181 | | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 | |
| 182 | 182 | | `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 | ||
| 11 | 11 | |
| 12 | 12 | 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. |
| 13 | 13 | |
| 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 | ||
| 14 | 16 | 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): | ||
| 318 | 318 | return { done, total, finishesOn: new Date(now.getTime() + left * 86_400_000).toISOString().slice(0, 10) }; |
| 319 | 319 | } |
| 320 | 320 | |
| 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 | ||
| 321 | 325 | // ---- the crew's gate ---- |
| 322 | 326 | |
| 323 | 327 | /** 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"; | ||
| 3 | 3 | import { audienceFor } from "../../../core/audience.ts"; |
| 4 | 4 | import { upstreamFacts } from "../../../actions/upstream_follow.ts"; |
| 5 | 5 | 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"; | |
| 7 | 8 | |
| 8 | 9 | export const tool: Tool = { |
| 9 | 10 | 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)", | |
| 11 | 12 | description: |
| 12 | 13 | "One repository you can read, as GET /api/v1/repos/:owner/:name.", |
| 13 | 14 | inputSchema: { |
| 14 | 15 | type: "object", |
| 15 | properties: { repo: REPO_ARG }, | |
| 16 | properties: { repo: REPO_ARG, view: { type: "string", enum: ["chapters"] }, chapter: { type: "string" } }, | |
| 16 | 17 | required: ["repo"], |
| 17 | 18 | }, |
| 18 | 19 | writes: false, |
| 19 | 20 | async run(ctx, user, args) { |
| 20 | 21 | const repo = repoArg(ctx, user, args); |
| 21 | 22 | if (isAnswer(repo)) return repo; |
| 23 | if (args["view"] === undefined && args["chapter"] !== undefined) return bad("invalid", "`chapter` needs `view: chapters`"); | |
| 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 | } | |
| 22 | 39 | return good({ |
| 23 | 40 | repo: `${repo.ownerName}/${repo.name}`, |
| 24 | 41 | description: repo.description, |
src/tui/screens/chapters.ts +4 −4
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | 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"; | |
| 13 | 13 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 14 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 15 | import type { Command } from "../app/palette.ts"; |
| @@ -25,8 +25,8 @@ const isChapterEvent = (kind: string): boolean => kind.startsWith("chapter.") || | ||
| 25 | 25 | /** The words under a chapter's name: what done means, then how far it is. */ |
| 26 | 26 | function chapterRow(f: Session, c: Chapter): ListRow { |
| 27 | 27 | 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}` }; | |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | function itemRow(i: Item): ListRow { |
| @@ -59,7 +59,7 @@ export function chapterScreen(f: Session, ctx: Ctx, repo: Repo, name: string): S | ||
| 59 | 59 | head!.x, |
| 60 | 60 | head!.y, |
| 61 | 61 | 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!)}`, | |
| 63 | 63 | head!.w, |
| 64 | 64 | ), |
| 65 | 65 | style(c.mutedFg, c.bg), |
src/web/pages/chapters.ts +2 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { canAdmin, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| @@ -113,7 +113,7 @@ export function chapterPage(ctx: Ctx): Response { | ||
| 113 | 113 | const left = items.filter((i) => i.status === "left"); |
| 114 | 114 | const done = items.filter((i) => i.status === "done"); |
| 115 | 115 | 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> | |
| 117 | 117 | ${items.length === 0 ? html`<p>No issues in this chapter yet.</p>` : null} |
| 118 | 118 | <ul class="list setting-list">${[...done, ...building, ...left].map((i) => itemRow(repo, i, may, c))}</ul> |
| 119 | 119 | ${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 | }); |
new above
offline
chaptergiven withoutviewis silently ignored and returns the plain repo answer. Returninvalid("chapterneedsview: chapters") and test it.