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

MCP `explore` gains a `tree` view: an app's fork chain in the page's own words, no new tool #11405

Merged opened this

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

Merged

mason/forge:mason/11377-mcp-explore-gains-a-tree-view-an-app-s-f → main 3 commits fork

  • Merged. mason/11377-mcp-explore-gains-a-tree-view-an-app-s-f was merged into main as c0d0545.

Review

Sign in to review this change.
4 files changed +195 −3
docs/MCP.md +14 −1
@@ -97,7 +97,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
9797 | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 |
9898 | `email_verify` | — | Send yourself a new verification link | 126 |
9999 | `events` | — | Repository events | 259 |
100 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 573 |
100 | `explore` | — | The apps, people (and, for an admin, forges) you can see, ranked | 641 |
101101 | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 |
102102 | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 |
103103 | `feed` | — | Your ranked home timeline; `cursor` pages; saved filter applies | 562 |
@@ -283,6 +283,19 @@ each the figures on the team's page from `teamMetrics` in
283283 `costPerShippedUsd`, `shipped`, `parallelism` and `slots`; a figure with
284284 nothing to measure is `null`. Only an org admin reads them, as the page.
285285
286 `explore` with `{ kind: "tree", app: "owner/name" }` is another (#11377): the
287 app's fork chain as plain rows, `{ app, upstream, forks, truncated }`, each row
288 `{ repo, follows, standing, updatedAt }`. `upstream` is the chain above the app,
289 nearest first, ending at the highest ancestor you may read; `forks` are its
290 direct forks (at most 60). `follows` is the Following card's line
291 (`followModeWords`: "Follows x automatically", "Proposes updates from x",
292 "Does not follow x"; "This repository follows nothing." at the top),
293 `standing` the forks page's drift words ("2 ahead · 0 behind", or "standing
294 unknown"), `updatedAt` its "pushed" time. Nothing records when a fork last took
295 updates, so the last move of its default branch stands for it. A fork you
296 cannot read is not a row (`src/actions/fork_tree_rows.ts`);
297 `test/mcp-tree.test.ts` holds the words to the page.
298
286299 `system` with `{ kind: "csp" }` is another (#10906): the last 24 hours of
287300 Content-Security-Policy violations browsers reported to `/csp-report`,
288301 `{ hours: 24, since, directives: [{ directive, n, newest }] }`, most first,
src/actions/fork_tree_rows.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * An app's fork chain as plain rows, in the words of its forks page: the
3 * repo itself, the upstream chain above it (nearest first, ending at the
4 * highest ancestor the caller may read) and its direct forks. Each row says
5 * how it follows its own parent (`followModeWords`, the Following card's
6 * line) and where it stands against it (`driftWords`, the page's meta line).
7 * A fork the caller cannot read is not a row and not counted.
8 */
9
10 import type { App } from "../app.ts";
11 import { canRead, listAncestors, visibleForks, type Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { followModeOf } from "./follow.ts";
14 import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts";
15 import { driftOfForks, driftWords, MAX_RANKED_FORKS } from "./forks.ts";
16
17 /** Ancestors listed above the repo; a chain longer than this is cut at the top. */
18 export const MAX_UPSTREAM = 8;
19
20 export interface TreeRow {
21 readonly repo: string;
22 /** How it follows its parent, in the Following card's line; `follows nothing` at the top of the chain. */
23 readonly follows: string;
24 /** Commits ahead and behind its parent, as the forks page words them; null at the top. */
25 readonly standing: string | null;
26 /** When its default branch last moved (ISO), as the page's "pushed"; null when unknown. */
27 readonly updatedAt: string | null;
28 }
29
30 export interface TreeRows {
31 readonly app: TreeRow;
32 readonly upstream: TreeRow[];
33 readonly forks: TreeRow[];
34 /** More forks exist than the rows hold. */
35 readonly truncated: boolean;
36 }
37
38 const nameOf = (r: Repo): string => `${r.ownerName}/${r.name}`;
39
40 async function rowOf(app: App, repo: Repo, parent: Repo | undefined): Promise<TreeRow> {
41 if (parent === undefined) return { repo: nameOf(repo), follows: FOLLOWS_NOTHING, standing: null, updatedAt: null };
42 const [ranked] = await driftOfForks(app, parent, [repo]);
43 return {
44 repo: nameOf(repo),
45 follows: followModeWords(await followModeOf(app, repo), nameOf(parent)).line,
46 standing: ranked === undefined ? null : driftWords(ranked.drift),
47 updatedAt: ranked?.pushedAt ?? null,
48 };
49 }
50
51 /** The rows around `repo`; the caller has already shown they may read it. */
52 export async function forkTreeRows(app: App, repo: Repo, user: User | undefined): Promise<TreeRows> {
53 const chain: Repo[] = [];
54 for (const up of listAncestors(app.db, repo.id, MAX_UPSTREAM)) {
55 if (!canRead(app.db, up, user)) break;
56 chain.push(up);
57 }
58 const all = visibleForks(app.db, repo.id, user);
59 const shown = all.slice(0, MAX_RANKED_FORKS);
60 const [self, upstream, forks] = await Promise.all([
61 rowOf(app, repo, chain[0]),
62 Promise.all(chain.map((r, i) => rowOf(app, r, chain[i + 1]))),
63 Promise.all(shown.map((f) => rowOf(app, f, repo))),
64 ]);
65 return { app: self, upstream, forks, truncated: all.length > shown.length };
66 }
src/http/mcp/tools/explore.ts +8 −2
@@ -1,9 +1,10 @@
11 import { explorePage, exploreLine, exploreLineFrom, EXPLORE_PARAMS, exploreNote, exploreSuggestion, readExploreLine } from "../../../actions/explore.ts";
2 import { bad, good, type Tool, str } from "../shared.ts";
2 import { forkTreeRows } from "../../../actions/fork_tree_rows.ts";
3 import { bad, good, type Tool, str, repoArg, isAnswer } from "../shared.ts";
34
45 export const tool: Tool = {
56 name: "explore",
6 doc: "`{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; each also as its own argument (`since` = `updated`); page on with `cursor`",
7 doc: "`{ kind: tree, app }`: `{ app, upstream, forks, truncated }`, rows `{ repo, follows, standing, updatedAt }` for the app, the chain above it (nearest first) and its direct forks, in the words of the forks page, only what you may read; else `{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; each also as its own argument (`since` = `updated`); page on with `cursor`",
78 description:
89 "The apps, people (and, for an admin, forges) you can see, ranked.",
910 inputSchema: {
@@ -21,6 +22,7 @@ export const tool: Tool = {
2122 },
2223 ...Object.fromEntries(EXPLORE_PARAMS.map((k) => [k, { type: "string", maxLength: 100 }])),
2324 since: { type: "string", maxLength: 100 },
25 app: { type: "string", maxLength: 200, description: "owner/name, with kind: tree" },
2426 cursor: {
2527 type: "string",
2628 },
@@ -28,6 +30,10 @@ export const tool: Tool = {
2830 },
2931 writes: false,
3032 async run(ctx, user, args) {
33 if (str(args["kind"]) === "tree") {
34 const repo = repoArg(ctx, user, { repo: args["app"] });
35 return isAnswer(repo) ? repo : good(await forkTreeRows(ctx.app, repo, user));
36 }
3137 const q = str(args["q"]);
3238 const line = exploreLineFrom((name) => (name === "q" ? (q !== "" ? q : str(args["query"])) : str(args[name])));
3339 const { query, unknown } = readExploreLine(line);
test/mcp-tree.test.ts added +107
@@ -0,0 +1,107 @@
1 /** `explore` with `kind: tree`: an app's fork chain in the words of its forks page, and only what the caller may read (#11377). */
2 import { afterAll, beforeAll, describe, 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 { createRepo, forkRepo } from "../src/actions/repos.ts";
8 import { setFollowMode } from "../src/actions/follow.ts";
9 import { followModeWords } from "../src/actions/follow_words.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { proveAll } from "./helpers/verified.ts";
15 import { promote } from "./helpers/members.ts";
16
17 quietLogs();
18
19 let dir: string;
20 let app: App;
21 let servers: Servers;
22 let aliceToken: string;
23 let bobToken: string;
24 let cookie: string;
25
26 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
27 if (!r.ok) throw new Error(JSON.stringify(r.error));
28 return r.value;
29 };
30
31 const mk = async (username: string, first: boolean): Promise<User> =>
32 must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }));
33
34 async function tree(token: string, args: Record<string, unknown>): Promise<any> {
35 const res = await fetch(`${servers.url}/mcp`, {
36 method: "POST",
37 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
38 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: args } }),
39 });
40 const body = (await res.json()) as any;
41 const text = body.result.content[0].text as string;
42 return body.result.isError === true ? { error: text.split(":")[0] } : JSON.parse(text);
43 }
44
45 beforeAll(async () => {
46 dir = await mkdtemp(join(tmpdir(), "forge-mcp-tree-"));
47 app = createApp({ dataDir: dir, port: 0 });
48 servers = await serve(app);
49 const alice = await mk("alice", true);
50 const bob = await mk("bob", false);
51 const carol = await mk("carol", false);
52 proveAll(app);
53 for (const u of [alice, bob, carol]) promote(app.db, u);
54 aliceToken = createToken(app.db, alice.id, "t", "admin").token;
55 bobToken = createToken(app.db, carol.id, "t", "admin").token;
56 const root = must(await createRepo(app, alice, { name: "lib", visibility: "public" }));
57 const mid = must(await forkRepo(app, alice, root, "lib-mid"));
58 must(await forkRepo(app, alice, mid, "lib-leaf"));
59 const secret = must(await forkRepo(app, bob, mid, "lib-secret"));
60 app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [secret.id]);
61 await setFollowMode(app, alice, mid, "take-updates");
62 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" });
63 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
64 }, 30_000);
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dir, { recursive: true, force: true });
70 });
71
72 describe("explore kind: tree", () => {
73 test("names the upstream chain and the direct forks", async () => {
74 const t = await tree(aliceToken, { kind: "tree", app: "alice/lib-mid" });
75 expect(t.app.repo).toBe("alice/lib-mid");
76 expect(t.upstream.map((r: any) => r.repo)).toEqual(["alice/lib"]);
77 expect(t.forks.map((r: any) => r.repo).sort()).toEqual(["alice/lib-leaf", "bob/lib-secret"]);
78 expect(t.truncated).toBe(false);
79 expect(t.upstream[0].follows).toBe("This repository follows nothing.");
80 });
81
82 test("says how each follows, in the page's words", async () => {
83 const t = await tree(aliceToken, { kind: "tree", app: "alice/lib-mid" });
84 expect(t.app.follows).toBe(followModeWords("take-updates", "alice/lib").line);
85 expect(t.forks.find((r: any) => r.repo === "alice/lib-leaf").follows).toBe(followModeWords("tell-me", "alice/lib-mid").line);
86 expect(t.app.standing).toBe("standing unknown");
87 });
88
89 test("parity: the forks page carries the same words", async () => {
90 const t = await tree(aliceToken, { kind: "tree", app: "alice/lib-mid" });
91 const page = await (await fetch(`${servers.url}/alice/lib-mid/forks`, { headers: { cookie } })).text();
92 expect(page).toContain(t.app.follows);
93 expect(page).toContain(t.forks.find((r: any) => r.repo === "alice/lib-leaf").standing);
94 expect(page).toContain("alice/lib-leaf");
95 });
96
97 test("a private fork is omitted for a caller who cannot read it", async () => {
98 const t = await tree(bobToken, { kind: "tree", app: "alice/lib-mid" });
99 expect(t.forks.map((r: any) => r.repo)).toEqual(["alice/lib-leaf"]);
100 expect(JSON.stringify(t)).not.toContain("lib-secret");
101 });
102
103 test("an app the caller cannot read is not found", async () => {
104 expect((await tree(bobToken, { kind: "tree", app: "bob/lib-secret" })).error).toBe("not_found");
105 expect((await tree(bobToken, { kind: "tree", app: "alice/nope" })).error).toBe("not_found");
106 });
107 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs