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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11377-mcp-explore-gains-a-tree-view-an-app-s-f → main 3 commits fork
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`; | ||
| 97 | 97 | | `egress_grants` | — | Private destinations and grants: list, grant or revoke (site admin) | 289 | |
| 98 | 98 | | `email_verify` | — | Send yourself a new verification link | 126 | |
| 99 | 99 | | `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 | | |
| 101 | 101 | | `family` | — | The forge that sent this one and, for a site admin, those it sent, with state, version | 205 | |
| 102 | 102 | | `features` | — | Apps absorbed as forge features: list, read, absorb or un-absorb, change typed settings | 357 | |
| 103 | 103 | | `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 | ||
| 283 | 283 | `costPerShippedUsd`, `shipped`, `parallelism` and `slots`; a figure with |
| 284 | 284 | nothing to measure is `null`. Only an org admin reads them, as the page. |
| 285 | 285 | |
| 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 | ||
| 286 | 299 | `system` with `{ kind: "csp" }` is another (#10906): the last 24 hours of |
| 287 | 300 | Content-Security-Policy violations browsers reported to `/csp-report`, |
| 288 | 301 | `{ 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 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 4 | |
| 4 | 5 | export const tool: Tool = { |
| 5 | 6 | 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`", | |
| 7 | 8 | description: |
| 8 | 9 | "The apps, people (and, for an admin, forges) you can see, ranked.", |
| 9 | 10 | inputSchema: { |
| @@ -21,6 +22,7 @@ export const tool: Tool = { | ||
| 21 | 22 | }, |
| 22 | 23 | ...Object.fromEntries(EXPLORE_PARAMS.map((k) => [k, { type: "string", maxLength: 100 }])), |
| 23 | 24 | since: { type: "string", maxLength: 100 }, |
| 25 | app: { type: "string", maxLength: 200, description: "owner/name, with kind: tree" }, | |
| 24 | 26 | cursor: { |
| 25 | 27 | type: "string", |
| 26 | 28 | }, |
| @@ -28,6 +30,10 @@ export const tool: Tool = { | ||
| 28 | 30 | }, |
| 29 | 31 | writes: false, |
| 30 | 32 | 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 | } | |
| 31 | 37 | const q = str(args["q"]); |
| 32 | 38 | const line = exploreLineFrom((name) => (name === "q" ? (q !== "" ? q : str(args["query"])) : str(args[name]))); |
| 33 | 39 | 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 | }); |