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

MCP explore, feed and forks draw ui:// cards, as held and board do #11502

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

Ready to land

mason/forge:mason/11491-mcp-explore-feed-and-forks-draw-ui-cards → main 5 commits fork 6 threads · 6 unresolved

Review

Sign in to review this change.
7 files changed +158 −5
docs/MCP.md +3 −3
@@ -115,7 +115,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
115115 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 387 |
116116 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
117117 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 |
118 | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 387 |
118 | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 437 |
119119 | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 |
120120 | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 |
121121 | `held` | — | What the Later gate holds, and one proposed pass over it | 319 |
@@ -519,13 +519,13 @@ answers
519519 | `ui://forge/pulls` | `pulls` | Pulls, at most 20: `#N title`, state, check and review word, each linked. |
520520 | `ui://forge/runs` | `runs` | Recent runs, newest first, linked to their logs. |
521521 | `ui://forge/asked` | `asked` | What people asked the crew for and where each ask stands. |
522 | `ui://forge/explore` | `explore` | Apps, people and forges ranked, at most 20: name, kind, the numbers (and likes or ship time when sorted by them), linked; only what you may see. |
522 | `ui://forge/explore` | `explore` | Apps, people and forges ranked, at most 20: name, kind, the numbers (and likes or ship time when sorted by them), linked; only what you may see. With `kind: tree`: upstream, this and fork rows, each with how it follows and linked to its repository. |
523523 | `ui://forge/issue` | `issue` | One issue: title, state, assignee, labels, what it waits for. |
524524 | `ui://forge/flow` | `flow` | The pipeline: per stage counts, oldest waiting, the bottleneck. |
525525 | `ui://forge/scorecard` | `scorecard` | The crew's scorecard per agent (forge admin). |
526526 | `ui://forge/crew` | `now` | What the crew is doing right now, with Stop buttons. |
527527 | `ui://forge/board` | `boards` | One board's panels (person admins). |
528 | `ui://forge/forks` | `fork_tree` | The fork tree around a repository, each row with how it follows. |
528 | `ui://forge/forks` | `fork_tree`, `forks` | The fork tree around a repository, each row with how it follows; `forks` a row per fork with its commits ahead and behind, `kind: chain` its chain (a severed chain says so); each linked to the repository, at most 30 and one More row. |
outdated

Wrong row: explore with kind: tree keeps card kind explore (resource ui://forge/explore), not ui://forge/forks. List only fork_tree and forks here and describe the tree answer in the ui://forge/explore row. The card-table test should pin it.

outdated

Wrong: explore with kind: tree draws the explore card (ui://forge/explore), not forks. Remove it from this row and say in the ui://forge/explore row that its tree answer draws upstream, this and fork rows linked to each repository. Also name that in the explore tool doc so the harness and the table agree.

529529 | `ui://forge/milestones` | `milestones` | The milestones and how far each is. |
530530 | `ui://forge/search` | `search` | The query on top, one row per hit (repository, issue, pull, code) with its kind, title and link. |
531531 | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. |
src/http/mcp/tools/explore.ts +1 −1
@@ -4,7 +4,7 @@ import { bad, good, type Tool, str, repoArg, isAnswer } from "../shared.ts";
44
55 export const tool: Tool = {
66 name: "explore",
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 and `why` (a short line on the ranking, absent when none); `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, drawn on the `ui://forge/explore` card as upstream, this and fork rows; else `{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers and `why` (a short line on the ranking, absent when none); `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; each also as its own argument (`since` = `updated`); page on with `cursor`",
88 description:
99 "The apps, people (and, for an admin, forges) you can see, ranked.",
1010 inputSchema: {
src/http/mcp/tools/forks.ts +1 −1
@@ -6,7 +6,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
66
77 export const tool: Tool = {
88 name: "forks",
9 doc: "`{ repo, kind: chain }`: `{ chain: [{ role, repo, follows, page }], severed, truncated }`, its grandparent, parent, itself and children in the fork tree page's words, a severed link stops it, unreadable forks are left out; else `{ 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 doc: "`{ repo, kind: chain }`: `{ chain: [{ role, repo, follows, page }], severed, truncated }`, its grandparent, parent, itself and children in the fork tree page's words, a severed link stops it, unreadable forks are left out; else `{ 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; draws the `ui://forge/forks` card",
1010 description: "A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind.",
1111 inputSchema: {
1212 type: "object",
src/web/studio/cards.ts +42
@@ -59,6 +59,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5959 map: "map",
6060 feed: "feed",
6161 explore: "explore",
62 forks: "forks",
6263 new_app: "app",
6364 archive: "app",
6465 pull: "pull",
@@ -177,6 +178,34 @@ const previewWords = (previews: unknown): string =>
177178 list(previews).map(obj).filter((p) => web(text(p["url"]))).slice(0, 3)
178179 .map((p) => ` · pull #${text(p["pull"])} ${text(p["url"])}`).join("");
179180
181 type LinkOf = (repo: string) => { link?: string };
182 type Drawn = { rows: CardRow[]; status: string };
183
184 /** `forks` page `{ parent, forks: [{ repo, ahead, behind }] }`: a row per fork with its standing. */
185 function forksPageRows(v: Obj, at: LinkOf): Drawn {
186 const forks = list(v["forks"]).map(obj);
187 const rows = forks.map((f): CardRow => {
188 const known = Number.isInteger(f["ahead"]) && Number.isInteger(f["behind"]);
189 return { label: text(f["repo"]), value: known ? `${text(f["ahead"])} ahead · ${text(f["behind"])} behind` : "standing unknown", ...at(text(f["repo"])) };
190 });
191 return { rows, status: `${forks.length} fork${forks.length === 1 ? "" : "s"}${text(v["parent"]) === "" ? "" : ` of ${text(v["parent"])}`}` };
192 }
193
194 /** `forks` with `kind: chain` `{ chain: [{ role, repo, follows }], severed }`: a row per link; a severed chain says so. */
195 function forkChainRows(v: Obj, at: LinkOf): Drawn {
196 const chain = list(v["chain"]).map(obj);
197 const rows = chain.map((c): CardRow => ({ label: text(c["repo"]), value: `${text(c["role"])} · ${text(c["follows"])}`, ...at(text(c["repo"])) }));
198 if (v["severed"] === true) rows.push({ label: "Severed", value: "a link above is cut; the chain stops here" });
199 return { rows, status: `${chain.length} in the chain${v["severed"] === true ? " · severed" : ""}` };
200 }
201
202 /** `explore` with `kind: tree` `{ app, upstream, forks }`: upstream from the top, then this, then its forks. */
203 function exploreTreeRows(v: Obj, at: LinkOf): Drawn {
204 const row = (r: Obj, role: string): CardRow => ({ label: text(r["repo"]), value: [role, text(r["follows"]), text(r["standing"])].filter((x) => x !== "").join(" · "), ...at(text(r["repo"])) });
205 const rows = [...list(v["upstream"]).map(obj).reverse().map((r) => row(r, "upstream")), row(obj(v["app"]), "this"), ...list(v["forks"]).map(obj).map((r) => row(r, "fork"))];
206 return { rows, status: `${rows.length} repositor${rows.length === 1 ? "y" : "ies"} around ${text(obj(v["app"])["repo"])}` };
207 }
208
180209 /** The card for a tool's answer, or nothing when the answer has no card. */
181210 export function cardFor(
182211 tool: string,
@@ -267,6 +296,19 @@ export function cardFor(
267296 actions.push({ label: `Close ${approve.length} done`, tool: "held", args: { app: slug, apply: approve, digest: text(v["digest"]) } });
268297 return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions };
outdated

Two different "More" rows can both appear with the same label; when total > FORK_ROWS and a cursor exist, the card shows two. Merge into one row with the count and the paging hint.

269298 }
299 if (tool === "forks" || (tool === "explore" && Array.isArray(obj(value)["upstream"]))) {
300 // Each answer has its own helper; the card kind stays the tool's (`forks` or `explore`).
301 const v = obj(value);
302 const at = (repo: string): { link?: string } => (web(origin) && slugOf(repo) ? { link: `${origin}/${repo}` } : {});
303 const drawn = tool === "explore" ? exploreTreeRows(v, at) : Array.isArray(v["chain"]) ? forkChainRows(v, at) : Array.isArray(v["forks"]) ? forksPageRows(v, at) : undefined;
304 if (drawn === undefined) return undefined;
305 rows.push(...drawn.rows.slice(0, FORK_ROWS));
306 const left = drawn.rows.length - FORK_ROWS;
307 const paged = v["truncated"] === true || (typeof v["next_cursor"] === "string" && v["next_cursor"] !== "");
308 if (left > 0 || paged)
309 rows.push({ label: "More", value: [left > 0 ? `${left} more not shown` : "", paged ? "more forks exist; page on with the tool" : ""].filter((x) => x !== "").join("; ") });
310 return { kind, title: CARD_TITLE[kind], status: drawn.status, rows, actions };
311 }
270312 if (kind === "forks") {
271313 // `fork_tree` answers `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`; a repo with no readable tree has no card.
272314 const v = obj(value);
test/mcp-card-table.test.ts +14
@@ -11,6 +11,7 @@ import { createApp, type App } from "../src/app.ts";
1111 import { createToken } from "../src/core/tokens.ts";
1212 import { createUser } from "../src/core/users.ts";
1313 import { serve, type Servers } from "../src/http/server.ts";
14 import { CARD_OF_TOOL } from "../src/web/studio/cards.ts";
1415 import { quietLogs } from "./helpers/globals.ts";
1516
1617 quietLogs();
@@ -53,3 +54,16 @@ test("docs/MCP.md has a table row for exactly the cards the server lists", async
5354 expect([...drawn].filter((u) => !described.has(u)).sort()).toEqual([]);
5455 expect([...described].filter((u) => !drawn.has(u)).sort()).toEqual([]);
5556 });
57
58 test("each tool a card row names without a qualifier draws that card", async () => {
59 const doc = await readFile(join(import.meta.dir, "..", "docs", "MCP.md"), "utf8");
60 const wrong: string[] = [];
61 for (const m of doc.matchAll(/^\| `ui:\/\/forge\/([a-z-]+)` \| ([^|]+) \|/gm)) {
62 for (const item of m[2]!.split(",").map((x) => x.trim())) {
63 const plain = /^`([a-z_]+)`$/.exec(item);
64 if (plain && CARD_OF_TOOL[plain[1]!] !== undefined && CARD_OF_TOOL[plain[1]!] !== m[1]) wrong.push(`${plain[1]} → ${m[1]}`);
65 if (/^`explore` with `kind: tree`/.test(item) && m[1] !== "explore") wrong.push(`explore tree → ${m[1]}`);
66 }
67 }
68 expect(wrong).toEqual([]);
69 });
test/mcp-cards-coverage.test.ts +8
@@ -59,6 +59,14 @@ describe("MCP cards coverage", () => {
5959 expect(missing).toEqual([]);
6060 });
6161
62 test("forks and explore point tools/list at their cards", async () => {
63 const tools = (await listVia((p) => rpc("tools/list", p))).result.tools;
64 const uri = (name: string) => tools.find((t: any) => t.name === name)?._meta?.ui?.resourceUri;
65 expect(uri("forks")).toBe("ui://forge/forks");
66 expect(uri("explore")).toBe("ui://forge/explore");
67 expect(uri("feed")).toBe("ui://forge/feed");
68 });
69
6270 test("every card a tool names is a card kind", () => {
6371 const kinds: readonly string[] = CARD_KINDS;
6472 const unknown = Object.values(CARD_OF_TOOL).filter((k) => !kinds.includes(k));
test/mcp-cards-explore.test.ts added +89
@@ -0,0 +1,89 @@
1 /** MCP `forks` draws `ui://forge/forks`, `explore` (ranked or `kind: tree`) `ui://forge/explore`, `feed` `ui://forge/feed`: linked rows, escaped, with and without rows. */
2 import { describe, expect, test } from "bun:test";
3 import { CARD_OF_TOOL, cardFor } from "../src/web/studio/cards.ts";
4
5 const O = "https://forge.test";
6 const EVIL = "<img src=x onerror=1>";
7
8 describe("forks and explore tree cards", () => {
9 test("forks is bound to the forks card", () => {
10 expect(CARD_OF_TOOL["forks"]).toBe("forks");
11 });
12
13 test("a forks page: one linked row per fork with ahead and behind, unknown said so", () => {
14 const c = cardFor("forks", { parent: "a/b", forks: [{ repo: "c/d", ahead: 2, behind: 1, updatedAt: null }, { repo: EVIL, ahead: null, behind: null, updatedAt: null }], next_cursor: null }, O)!;
15 expect(c.kind).toBe("forks");
16 expect(c.status).toBe("2 forks of a/b");
17 expect(c.rows[0]).toEqual({ label: "c/d", value: "2 ahead · 1 behind", link: `${O}/c/d` });
18 expect(c.rows[1]!.value).toBe("standing unknown");
19 expect(c.rows[1]!.link).toBeUndefined();
20 expect(c.rows[1]!.label).toBe(EVIL);
21 });
22
23 test("no forks: a card with no rows; a next cursor says more exist", () => {
24 const c = cardFor("forks", { parent: null, forks: [], next_cursor: null }, O)!;
25 expect(c.status).toBe("0 forks");
26 expect(c.rows).toEqual([]);
27 expect(cardFor("forks", { parent: null, forks: [{ repo: "c/d", ahead: 0, behind: 0 }], next_cursor: "x" }, O)!.rows).toHaveLength(2);
28 });
29
30 test("a chain: role and how it follows, linked", () => {
31 const c = cardFor("forks", { chain: [{ role: "parent", repo: "a/b", follows: "Follows nothing", page: "/a/b" }, { role: "this", repo: "c/d", follows: "takes updates", page: "/c/d" }], severed: false, truncated: false }, O)!;
32 expect(c.rows[1]).toEqual({ label: "c/d", value: "this · takes updates", link: `${O}/c/d` });
33 expect(cardFor("forks", { chain: [], severed: false, truncated: false }, O)!.rows).toEqual([]);
34 });
35
36 test("explore kind tree: upstream first, then itself and its forks, drawn as the explore card", () => {
37 const row = (repo: string, follows: string, standing: string | null) => ({ repo, follows, standing, updatedAt: null });
38 const c = cardFor("explore", { app: row("c/d", "takes updates", "1 ahead · 0 behind"), upstream: [row("a/b", "follows nothing", null)], forks: [row(EVIL, "off", "standing unknown")], truncated: true }, O)!;
39 expect(c.kind).toBe("explore");
40 expect(c.rows.map((r) => r.label)).toEqual(["a/b", "c/d", EVIL, "More"]);
41 expect(c.rows[1]!.value).toBe("this · takes updates · 1 ahead · 0 behind");
42 expect(c.rows[0]!.link).toBe(`${O}/a/b`);
43 const none = cardFor("explore", { app: row("c/d", "off", null), upstream: [], forks: [], truncated: false }, O)!;
44 expect(none.rows).toHaveLength(1);
45 });

The issue asks for feed too: add a test that a feed answer with rows and with none draws the feed card, so the claim that it already works is pinned. Also assert that tools/list for forks points at ui://forge/forks.

46
47 test("a ranked explore answer still draws the ranked card", () => {
48 expect(cardFor("explore", { query: "", items: [] }, O)!.status).toBe("0 ranked");
49 });

Box 3 asks for each of explore, feed and forks rendered with rows and with none. feed has no empty case in a test added here, and the card-table doc row for explore tree is not pinned. Add the feed empty/with-rows case (or point to the existing test in mcp-cards.test.ts that covers both) and the 30-row cap case.

50
51 test("a severed chain says so in a row and the status", () => {
52 const c = cardFor("forks", { chain: [{ role: "this", repo: "c/d", follows: "off" }], severed: true, truncated: false }, O)!;
53 expect(c.status).toBe("1 in the chain · severed");
54 expect(c.rows.at(-1)!.label).toBe("Severed");
55 });
56
57 test("over 30 forks: 30 rows and one More row with the count", () => {
58 const forks = Array.from({ length: 31 }, (_, i) => ({ repo: `o/f${i}`, ahead: 0, behind: 0 }));
59 const c = cardFor("forks", { parent: null, forks, next_cursor: null }, O)!;
60 expect(c.rows).toHaveLength(31);
61 expect(c.rows[30]).toEqual({ label: "More", value: "1 more not shown" });
62 });
63
64 test("over 30 and paged on: still one More row, with the count and the hint", () => {
65 const forks = Array.from({ length: 32 }, (_, i) => ({ repo: `o/f${i}`, ahead: 0, behind: 0 }));
66 const c = cardFor("forks", { parent: null, forks, next_cursor: "x" }, O)!;
67 expect(c.rows.filter((r) => r.label === "More")).toEqual([{ label: "More", value: "2 more not shown; more forks exist; page on with the tool" }]);
68 });
69
70 test("a truncated chain ends in one More row", () => {
71 const c = cardFor("forks", { chain: [{ role: "this", repo: "c/d", follows: "off" }], severed: false, truncated: true }, O)!;
72 expect(c.rows.at(-1)).toEqual({ label: "More", value: "more forks exist; page on with the tool" });
73 });
74
75 test("feed with rows: linked and escaped; with none: an empty card", () => {
76 const c = cardFor("feed", { items: [{ actor: "a", what: EVIL, link: "/a/b", app: "a/b", at: "" }], new: 1 }, O)!;
77 expect(c.kind).toBe("feed");
78 expect(c.status).toBe("1 new since you last looked");
79 expect(c.rows[0]).toEqual({ label: `a: ${EVIL}`, value: "a/b", link: `${O}/a/b` });
80 const none = cardFor("feed", { items: [] }, O)!;
81 expect(none.kind).toBe("feed");
82 expect(none.rows).toEqual([]);
83 });
84
85 test("explore ranked with rows draws linked rows", () => {
86 const c = cardFor("explore", { query: "", items: [{ kind: "app", name: EVIL, url: "/a/b", numbers: { forks: 1, ships: 2, awakeHours: 3 } }] }, O)!;
87 expect(c.rows[0]).toEqual({ label: `app · ${EVIL}`, value: "1 forks · 2 ships · 3 h awake", link: `${O}/a/b` });
88 });
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs