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

MCP `forks`, in its own tool file: a repository's forks the caller may see, each with how far ahead and behind #7050

Merged opened this

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

Merged

scout/forge:scout/5963-mcp-forks-in-its-own-tool-file-a-reposit → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/5963-mcp-forks-in-its-own-tool-file-a-reposit was merged into main as 1eedfd4.

Review

Sign in to review this change.
5 files changed +127 −0
docs/MCP.md +1
@@ -83,6 +83,7 @@ for a tunnel and a forge that sleeps.
8383 | `following` | where a fork stands against what it follows, and what the crew is doing about it | |
8484 | `forge_proposals` | `{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer | |
8585 | `forges` | an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity | |
86 | `forks` | `{ 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 | |
8687 | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes |
8788 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
8889 | `here` | `{ url }`: on a forge page, what you may do there as who you are, each action with its route and tool; on the link of one of this forge's app hosts, what its `/__forge/` door shows: `{ app, repo, environment, pull: { number, title } or null, working: [names], links: { home, repo, report } }`; a foreign host, or a private app you may not enter, is refused like a missing page | |
src/http/mcp/tools/forks.ts added +27
@@ -0,0 +1,27 @@
1 import { driftOfForks } from "../../../actions/forks.ts";
2 import { forkParentFor, visibleForks } from "../../../core/repos.ts";
3 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 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"] },
10 writes: false,
11 async run(ctx, user, args) {
12 const repo = repoArg(ctx, user, args);
13 if (isAnswer(repo)) return repo;
14 const parent = forkParentFor(ctx.app.db, repo, user);
15 const ranked = await driftOfForks(ctx.app, repo, visibleForks(ctx.app.db, repo.id, user));

Non-blocking: driftOfForks slices to MAX_RANKED_FORKS (60) and says nothing, so on a repo with more visible forks the agent gets a partial list it can't tell is partial. rankedForksOf reports truncated for the web page; a truncated: visible.length > MAX_RANKED_FORKS (or a total) in this answer would keep it honest. Worth a follow-up.

16 const rows = ranked.map(({ fork, drift, pushedAt }) => ({
17 repo: `${fork.ownerName}/${fork.name}`,
18 ahead: drift.ahead,
19 behind: drift.behind,
20 updatedAt: pushedAt,
21 }));
outdated

High (DoS and duplication): for each fork this runs forkDrift, which resolves the parent's tip again, then an uncached rev-list, then a separate git log. With 60 forks that is about 240 concurrent git spawns on every call. driftOfForks(ctx.app, repo, forks) in src/actions/forks.ts already returns { drift, pushedAt } for each fork, capped and cached by tips, and it is what the page and the TUI use. Call it and map pushedAt to updatedAt.

22 return good({
23 parent: parent === undefined ? null : `${parent.ownerName}/${parent.name}`,
24 forks: rows,
25 });
26 },
27 };
src/http/mcp/tools/index.ts +1
@@ -56,6 +56,7 @@ export { tool as follow_mode } from "./follow_mode.ts";
5656 export { tool as following } from "./following.ts";
5757 export { tool as forge_proposals } from "./forge_proposals.ts";
5858 export { tool as forges } from "./forges.ts";
59 export { tool as forks } from "./forks.ts";
5960 export { tool as front_page } from "./front_page.ts";
6061 export { tool as health } from "./health.ts";
6162 export { tool as here } from "./here.ts";
test/mcp.test.ts +1
@@ -249,6 +249,7 @@ describe("the forge over MCP", () => {
249249 "sever",
250250 "flag_set",
251251 "follow_mode",
252 "forks",
252253 "upstream",
253254 "propose_to_parent_forge",
254255 "forge_proposals",
test/mcp/forks.test.ts added +97
@@ -0,0 +1,97 @@
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";
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 { commitToBranch } from "../../src/actions/repos.ts";
8 import { createRepo, forkRepo as cloneRepo } from "../../src/core/repos.ts";
9 import { createToken } from "../../src/core/tokens.ts";
10 import { createUser } from "../../src/core/users.ts";
11 import { serve, type Servers } from "../../src/http/server.ts";
12 import { quietLogs } from "../helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 const tok: Record<string, string> = {};
20
21 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
22 if (!r.ok) throw new Error(JSON.stringify(r.error));
23 return r.value;
24 };
25
26 async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
27 const res = await fetch(`${servers.url}/mcp`, {
28 method: "POST",
29 headers: {
30 "content-type": "application/json",
31 accept: "application/json, text/event-stream",
32 authorization: `Bearer ${token}`,
33 },
34 body: JSON.stringify({
35 jsonrpc: "2.0",
36 id: 1,
37 method: "tools/call",
38 params: { name: "forks", arguments: args },
39 }),
40 });
41 const body = (await res.json()) as any;
42 const text: string = body.result.content[0].text;
43 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
44 }
45
46 beforeAll(async () => {
47 dataDir = await mkdtemp(join(tmpdir(), "forge-forks-mcp-"));
48 app = createApp({ dataDir, port: 0 });
49 servers = await serve(app);
50 const make = (username: string) =>
51 createUser(app.db, {
52 origin: "seed",
53 trusted: true,
54 username,
55 email: `${username}@forge.local`,
56 password: "a-good-passphrase",
57 });
58 const owner = ok(await make("ada"));
59 const bob = ok(await make("bob"));
60 const cy = ok(await make("cy"));
61 const dee = ok(await make("dee"));
62 const parent = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "svc", visibility: "public" }));
63 ok(await commitToBranch(app, owner, parent, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }));
64 const ahead = ok(await cloneRepo(app.db, app.reposRoot, parent, bob.id, "svc"));
65 const behind = ok(await cloneRepo(app.db, app.reposRoot, parent, cy.id, "svc"));
66 const hidden = ok(await cloneRepo(app.db, app.reposRoot, parent, dee.id, "svc", { visibility: "private" }));
67 ok(await commitToBranch(app, bob, ahead, { message: "mine", files: [{ path: "b.txt", content: "b\n" }] }));
68 ok(await commitToBranch(app, owner, parent, { message: "more", files: [{ path: "c.txt", content: "c\n" }] }));
69 void behind;
70 void hidden;
71 tok["ada"] = createToken(app.db, owner.id, "rw", "rw").token;
72 tok["bob"] = createToken(app.db, bob.id, "rw", "rw").token;
73 tok["dee"] = createToken(app.db, dee.id, "rw", "rw").token;
74 }, 30_000);
75
76 afterAll(async () => {
77 servers?.stop();
78 app?.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("two forks: one ahead, one behind; the private one is absent for a second caller", async () => {
83 const got = (await mcp(tok["ada"]!, { repo: "ada/svc" })).value;
84 expect(got.parent).toBeNull();
85 const by = Object.fromEntries(got.forks.map((f: any) => [f.repo, f]));
86 expect(Object.keys(by).sort()).toEqual(["bob/svc", "cy/svc"]);
87 expect(by["bob/svc"]).toMatchObject({ ahead: 1, behind: 1 });
88 expect(by["cy/svc"]).toMatchObject({ ahead: 0, behind: 1 });
89 expect(by["cy/svc"].updatedAt).toMatch(/^\d{4}-/);
90 });
91
92 test("the private fork's owner sees it, with its parent named", async () => {
93 const got = (await mcp(tok["dee"]!, { repo: "ada/svc" })).value;
94 expect(got.forks.map((f: any) => f.repo).sort()).toEqual(["bob/svc", "cy/svc", "dee/svc"]);
95 const own = (await mcp(tok["dee"]!, { repo: "dee/svc" })).value;
96 expect(own).toEqual({ parent: "ada/svc", forks: [] });
97 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs