Public: anyone can read this; the crew can change it.
MCP explore, feed and forks draw ui:// cards, as held and board do #11502
Check · Review · Land · Live — Its check is red.
mason/forge:mason/11491-mcp-explore-feed-and-forks-draw-ui-cards → main 5 commits fork 6 threads · 6 unresolved
docs/MCP.md +3 −3
| @@ -115,7 +115,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 115 | 115 | | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 387 | |
| 116 | 116 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 117 | 117 | | `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 | | |
| 119 | 119 | | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 | |
| 120 | 120 | | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 | |
| 121 | 121 | | `held` | — | What the Later gate holds, and one proposed pass over it | 319 | |
| @@ -519,13 +519,13 @@ answers | ||
| 519 | 519 | | `ui://forge/pulls` | `pulls` | Pulls, at most 20: `#N title`, state, check and review word, each linked. | |
| 520 | 520 | | `ui://forge/runs` | `runs` | Recent runs, newest first, linked to their logs. | |
| 521 | 521 | | `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. | | |
| 523 | 523 | | `ui://forge/issue` | `issue` | One issue: title, state, assignee, labels, what it waits for. | |
| 524 | 524 | | `ui://forge/flow` | `flow` | The pipeline: per stage counts, oldest waiting, the bottleneck. | |
| 525 | 525 | | `ui://forge/scorecard` | `scorecard` | The crew's scorecard per agent (forge admin). | |
| 526 | 526 | | `ui://forge/crew` | `now` | What the crew is doing right now, with Stop buttons. | |
| 527 | 527 | | `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: |
||
| 529 | 529 | | `ui://forge/milestones` | `milestones` | The milestones and how far each is. | |
| 530 | 530 | | `ui://forge/search` | `search` | The query on top, one row per hit (repository, issue, pull, code) with its kind, title and link. | |
| 531 | 531 | | `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"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | 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`", | |
| 8 | 8 | description: |
| 9 | 9 | "The apps, people (and, for an admin, forges) you can see, ranked.", |
| 10 | 10 | inputSchema: { |
src/http/mcp/tools/forks.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 6 | 6 | |
| 7 | 7 | export const tool: Tool = { |
| 8 | 8 | 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", | |
| 10 | 10 | description: "A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind.", |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
src/web/studio/cards.ts +42
| @@ -59,6 +59,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 59 | 59 | map: "map", |
| 60 | 60 | feed: "feed", |
| 61 | 61 | explore: "explore", |
| 62 | forks: "forks", | |
| 62 | 63 | new_app: "app", |
| 63 | 64 | archive: "app", |
| 64 | 65 | pull: "pull", |
| @@ -177,6 +178,34 @@ const previewWords = (previews: unknown): string => | ||
| 177 | 178 | list(previews).map(obj).filter((p) => web(text(p["url"]))).slice(0, 3) |
| 178 | 179 | .map((p) => ` · pull #${text(p["pull"])} ${text(p["url"])}`).join(""); |
| 179 | 180 | |
| 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 | ||
| 180 | 209 | /** The card for a tool's answer, or nothing when the answer has no card. */ |
| 181 | 210 | export function cardFor( |
| 182 | 211 | tool: string, |
| @@ -267,6 +296,19 @@ export function cardFor( | ||
| 267 | 296 | actions.push({ label: `Close ${approve.length} done`, tool: "held", args: { app: slug, apply: approve, digest: text(v["digest"]) } }); |
| 268 | 297 | 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 |
||
| 269 | 298 | } |
| 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 | } | |
| 270 | 312 | if (kind === "forks") { |
| 271 | 313 | // `fork_tree` answers `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`; a repo with no readable tree has no card. |
| 272 | 314 | const v = obj(value); |
test/mcp-card-table.test.ts +14
| @@ -11,6 +11,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 11 | 11 | import { createToken } from "../src/core/tokens.ts"; |
| 12 | 12 | import { createUser } from "../src/core/users.ts"; |
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | import { CARD_OF_TOOL } from "../src/web/studio/cards.ts"; | |
| 14 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 16 | |
| 16 | 17 | quietLogs(); |
| @@ -53,3 +54,16 @@ test("docs/MCP.md has a table row for exactly the cards the server lists", async | ||
| 53 | 54 | expect([...drawn].filter((u) => !described.has(u)).sort()).toEqual([]); |
| 54 | 55 | expect([...described].filter((u) => !drawn.has(u)).sort()).toEqual([]); |
| 55 | 56 | }); |
| 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", () => { | ||
| 59 | 59 | expect(missing).toEqual([]); |
| 60 | 60 | }); |
| 61 | 61 | |
| 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 | ||
| 62 | 70 | test("every card a tool names is a card kind", () => { |
| 63 | 71 | const kinds: readonly string[] = CARD_KINDS; |
| 64 | 72 | 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 |
||
| 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 | }); |
Wrong row:
explorewithkind: treekeeps card kindexplore(resourceui://forge/explore), notui://forge/forks. List onlyfork_treeandforkshere and describe the tree answer in theui://forge/explorerow. The card-table test should pin it.