Public: anyone can read this; the crew can change it.
The MCP forks tool pages with limit and cursor and reads drift for one page only, so a repository with hundreds of forks answers quickly #9108
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9100-the-mcp-forks-tool-pages-with-limit-and → main 2 commits fork
2 files
changed +62
−8
src/http/mcp/tools/forks.ts +22 −6
| @@ -1,18 +1,33 @@ | ||
| 1 | import { driftOfForks } from "../../../actions/forks.ts"; | |
| 2 | import { forkParentFor, visibleForks } from "../../../core/repos.ts"; | |
| 1 | import { driftOfForks, MAX_RANKED_FORKS, type RankedFork } from "../../../actions/forks.ts"; | |
| 2 | import { forkParentFor, visibleForks, type Repo } from "../../../core/repos.ts"; | |
| 3 | import { pageRows } from "../page.ts"; | |
| 3 | 4 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "forks", |
| 7 | doc: "`{ repo }`: `{ parent, forks: [{ repo, ahead, behind, updatedAt }] }`, the forks the caller may see (as `/:owner/:name/forks` lists them), each with its commits ahead of and behind this one; `null` when git cannot say", | |
| 8 | description: "A repo's forks you may see, each with commits ahead and behind.", | |
| 9 | inputSchema: { type: "object", properties: { repo: REPO_ARG }, required: ["repo"] }, | |
| 8 | doc: "`{ repo, limit?, cursor? }`: `{ parent, forks: [{ repo, ahead, behind, updatedAt }], next_cursor }`, the forks the caller may see in the order `/:owner/:name/forks` lists them, each with its commits ahead of and behind this one (`null` when git cannot say); `limit` 1-200 (default 50), `cursor` the last `next_cursor` (null on the last page); drift is read for the page only", | |
| 9 | description: "A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind.", | |
| 10 | inputSchema: { | |
| 11 | type: "object", | |
| 12 | properties: { | |
| 13 | repo: REPO_ARG, | |
| 14 | limit: { type: "integer", minimum: 1, maximum: 200 }, | |
| 15 | cursor: { type: "string" }, | |
| 16 | }, | |
| 17 | required: ["repo"], | |
| 18 | }, | |
| 10 | 19 | writes: false, |
| 11 | 20 | async run(ctx, user, args) { |
| 12 | 21 | const repo = repoArg(ctx, user, args); |
| 13 | 22 | if (isAnswer(repo)) return repo; |
| 14 | 23 | const parent = forkParentFor(ctx.app.db, repo, user); |
| 15 | const ranked = await driftOfForks(ctx.app, repo, visibleForks(ctx.app.db, repo.id, user)); | |
| 24 | const paged = pageRows(visibleForks(ctx.app.db, repo.id, user), (f) => f.id, args); | |
| 25 | if (!paged.ok) return paged; | |
| 26 | const { items, next_cursor } = paged.value as { items: Repo[]; next_cursor: string | null }; | |
| 27 | // Drift is read for this page only, in runs of what one call ranks. | |
| 28 | const ranked: RankedFork[] = []; | |
| 29 | for (let i = 0; i < items.length; i += MAX_RANKED_FORKS) | |
| 30 | ranked.push(...(await driftOfForks(ctx.app, repo, items.slice(i, i + MAX_RANKED_FORKS)))); | |
| 16 | 31 | const rows = ranked.map(({ fork, drift, pushedAt }) => ({ |
| 17 | 32 | repo: `${fork.ownerName}/${fork.name}`, |
| 18 | 33 | ahead: drift.ahead, |
| @@ -22,6 +37,7 @@ export const tool: Tool = { | ||
| 22 | 37 | return good({ |
| 23 | 38 | parent: parent === undefined ? null : `${parent.ownerName}/${parent.name}`, |
| 24 | 39 | forks: rows, |
| 40 | next_cursor, | |
| 25 | 41 | }); |
| 26 | 42 | }, |
| 27 | 43 | }; |
test/mcp/forks.test.ts +40 −2
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** MCP `forks`: ahead and behind per fork, and a private fork a second caller cannot see is absent. */ |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import * as drift from "../../src/actions/forks.ts"; | |
| 6 | 7 | import { createApp, type App } from "../../src/app.ts"; |
| 7 | 8 | import { commitToBranch } from "../../src/actions/repos.ts"; |
| 8 | 9 | import { createRepo, forkRepo as cloneRepo } from "../../src/core/repos.ts"; |
| @@ -93,5 +94,42 @@ test("the private fork's owner sees it, with its parent named", async () => { | ||
| 93 | 94 | const got = (await mcp(tok["dee"]!, { repo: "ada/svc" })).value; |
| 94 | 95 | expect(got.forks.map((f: any) => f.repo).sort()).toEqual(["bob/svc", "cy/svc", "dee/svc"]); |
| 95 | 96 | const own = (await mcp(tok["dee"]!, { repo: "dee/svc" })).value; |
| 96 | expect(own).toEqual({ parent: "ada/svc", forks: [] }); | |
| 97 | expect(own).toEqual({ parent: "ada/svc", forks: [], next_cursor: null }); | |
| 98 | }); | |
| 99 | ||
| 100 | test("pages by limit and cursor in the forks page's order, reading drift for the page only", async () => { | |
| 101 | const many = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "many", email: "[email protected]", password: "a-good-passphrase" })); | |
| 102 | const root = ok(await createRepo(app.db, app.reposRoot, { ownerId: many.id, name: "hub", visibility: "public" })); | |
| 103 | ok(await commitToBranch(app, many, root, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 104 | for (let i = 0; i < 5; i++) { | |
| 105 | const u = ok(await createUser(app.db, { origin: "seed", trusted: true, username: `f${i}`, email: `f${i}@forge.local`, password: "a-good-passphrase" })); | |
| 106 | ok(await cloneRepo(app.db, app.reposRoot, root, u.id, "hub")); | |
| 107 | } | |
| 108 | const t = createToken(app.db, many.id, "rw", "rw").token; | |
| 109 | const http = await fetch(`${servers.url}/api/v1/repos/many/hub/forks?limit=200`, { headers: { authorization: `Bearer ${t}` } }); | |
| 110 | const order: string[] = ((await http.json()) as any).items.map((f: any) => `${f.ownerName}/${f.name}`); | |
| 111 | expect(order.length).toBe(5); | |
| 112 | ||
| 113 | const spy = spyOn(drift, "driftOfForks"); | |
| 114 | try { | |
| 115 | const seen: string[] = []; | |
| 116 | let cursor: string | null = null; | |
| 117 | let pages = 0; | |
| 118 | do { | |
| 119 | spy.mockClear(); | |
| 120 | const got: any = (await mcp(t, { repo: "many/hub", limit: 2, ...(cursor === null ? {} : { cursor }) })).value; | |
| 121 | expect(got.forks.length).toBeLessThanOrEqual(2); | |
| 122 | const read = spy.mock.calls.reduce((n, c) => n + (c[2] as unknown[]).length, 0); | |
| 123 | expect(read).toBe(got.forks.length); | |
| 124 | seen.push(...got.forks.map((f: any) => f.repo)); | |
| 125 | cursor = got.next_cursor; | |
| 126 | pages++; | |
| 127 | } while (cursor !== null); | |
| 128 | expect(pages).toBe(3); | |
| 129 | expect(seen).toEqual(order); | |
| 130 | } finally { | |
| 131 | spy.mockRestore(); | |
| 132 | } | |
| 133 | expect((await mcp(t, { repo: "many/hub", cursor: "nope" })).error).toContain("cursor"); | |
| 134 | expect((await mcp(t, { repo: "many/hub", limit: 0 })).error).toContain("limit"); | |
| 97 | 135 | }); |
new above
offline