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

Merged opened this

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

Merged

scout/forge:scout/9100-the-mcp-forks-tool-pages-with-limit-and → main 2 commits fork

  • Merged. scout/9100-the-mcp-forks-tool-pages-with-limit-and was merged into main as 9655d21.

Review

Sign in to review this change.
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";
34 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
67 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 },
1019 writes: false,
1120 async run(ctx, user, args) {
1221 const repo = repoArg(ctx, user, args);
1322 if (isAnswer(repo)) return repo;
1423 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))));
1631 const rows = ranked.map(({ fork, drift, pushedAt }) => ({
1732 repo: `${fork.ownerName}/${fork.name}`,
1833 ahead: drift.ahead,
@@ -22,6 +37,7 @@ export const tool: Tool = {
2237 return good({
2338 parent: parent === undefined ? null : `${parent.ownerName}/${parent.name}`,
2439 forks: rows,
40 next_cursor,
2541 });
2642 },
2743 };
test/mcp/forks.test.ts +40 −2
@@ -1,8 +1,9 @@
11 /** 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";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import * as drift from "../../src/actions/forks.ts";
67 import { createApp, type App } from "../../src/app.ts";
78 import { commitToBranch } from "../../src/actions/repos.ts";
89 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 () => {
9394 const got = (await mcp(tok["dee"]!, { repo: "ada/svc" })).value;
9495 expect(got.forks.map((f: any) => f.repo).sort()).toEqual(["bob/svc", "cy/svc", "dee/svc"]);
9596 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");
97135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs