Public: anyone can read this; the crew can change it.
The MCP fork_tree tool answers with a `ui://forge/forks` card: each fork as a row with its depth and how it follows #11045
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11031-the-mcp-fork-tree-tool-answers-with-a-ui → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +138
−4
docs/MCP.md +2 −1
| @@ -114,7 +114,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 114 | 114 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 | |
| 115 | 115 | | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, stage, resend, spendTodayUsd, ceilingUsd, size, use | 231 | |
| 116 | 116 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 117 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 347 | | |
| 117 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 | | |
| 118 | 118 | | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 | |
| 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 | |
| @@ -491,6 +491,7 @@ answers | ||
| 491 | 491 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a |
| 492 | 492 | linked production row when the app has an http(s) URL; the `new_app` card links its |
| 493 | 493 | Production row the same way. Any other address stays plain text, never a link. |
| 494 | The Forks card (`fork_tree`, `ui://forge/forks`) is one row per repository you may read, nested by a `depth` field the card pads (not leading spaces, which HTML collapses); the value says how it follows: `follows on its own`, `takes updates`, `offers updates` (the card's own wording for the `offers` mode) or `off`. | |
| 494 | 495 | `resources/list` and `resources/read` serve one small |
| 495 | 496 | sandboxed page, which draws the `structuredContent.card` model that the |
| 496 | 497 | tool result carries, with `textContent` only. The model is built in |
src/http/mcp/tools/fork_tree.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 4 | 4 | /** The whole tree a repo sits in, as the caller may read it. */ |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "fork_tree", |
| 7 | doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates`, `offers` or `dont-follow`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown", | |
| 7 | doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates`, `offers` or `dont-follow`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown; drawn as the `ui://forge/forks` card", | |
| 8 | 8 | description: "The fork tree around a repo: ancestors, siblings and descendants you may read.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
src/web/studio/cards.ts +36 −2
| @@ -11,13 +11,15 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board"; | |
| 14 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board" | "forks"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| 18 | 18 | readonly value: string; |
| 19 | 19 | /** An absolute http(s) address the row's label links to. */ |
| 20 | 20 | readonly link?: string; |
| 21 | /** How many steps the row is nested (a tree): the card pads the label by this much. */ | |
| 22 | readonly depth?: number; | |
| 21 | 23 | } |
| 22 | 24 | export interface CardAction { |
| 23 | 25 | readonly label: string; |
| @@ -40,7 +42,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 40 | 42 | |
| 41 | 43 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 42 | 44 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 43 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board"]; | |
| 45 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board", "forks"]; | |
| 44 | 46 | |
| 45 | 47 | /** The tool whose answer each card draws. */ |
| 46 | 48 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -61,6 +63,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 61 | 63 | flow: "flow", |
| 62 | 64 | scorecard: "crew", |
| 63 | 65 | boards: "board", |
| 66 | fork_tree: "forks", | |
| 64 | 67 | }; |
| 65 | 68 | |
| 66 | 69 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -78,6 +81,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 78 | 81 | flow: "Flow", |
| 79 | 82 | crew: "Crew", |
| 80 | 83 | board: "Board", |
| 84 | forks: "Forks", | |
| 81 | 85 | }; |
| 82 | 86 | |
| 83 | 87 | type Obj = Record<string, unknown>; |
| @@ -93,6 +97,15 @@ const ASKED_WORDS = 80; | ||
| 93 | 97 | const ASKED_LIMIT = 20; |
| 94 | 98 | /** A board card shows this many panels, then counts the rest. */ |
| 95 | 99 | const BOARD_PANELS = 8; |
| 100 | /** A forks card shows this many nodes, then counts the rest. */ | |
| 101 | const FORK_ROWS = 30; | |
| 102 | /** How a fork follows its parent, in plain words. */ | |
| 103 | const FOLLOW_WORDS: Readonly<Record<string, string>> = { | |
| 104 | autonomous: "follows on its own", | |
| 105 | "take-updates": "takes updates", | |
| 106 | offers: "offers updates", | |
| 107 | "dont-follow": "off", | |
| 108 | }; | |
| 96 | 109 | /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */ |
| 97 | 110 | const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/; |
| 98 | 111 | |
| @@ -174,6 +187,26 @@ export function cardFor( | ||
| 174 | 187 | if (panels.length > BOARD_PANELS) rows.push({ label: "More", value: `${panels.length - BOARD_PANELS} more` }); |
| 175 | 188 | return { kind, title: text(v["title"]), status: `${panels.length} panel${panels.length === 1 ? "" : "s"} · ${text(v["range"])}`, rows, actions }; |
| 176 | 189 | } |
| 190 | if (kind === "forks") { | |
| 191 | // `fork_tree` answers `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`; a repo with no readable tree has no card. | |
| 192 | const v = obj(value); | |
| 193 | if (text(v["root"]) === "" || !Array.isArray(v["nodes"])) return undefined; | |
| 194 | const nodes = v["nodes"].map(obj); | |
| 195 | for (const n of nodes.slice(0, FORK_ROWS)) { | |
| 196 | const repo = text(n["repo"]); | |
| 197 | const depth = Math.min(8, Math.max(0, Math.floor(Number(n["depth"]) || 0))); | |
| 198 | rows.push({ | |
| 199 | label: repo, | |
| 200 | ...(depth > 0 ? { depth } : {}), | |
| 201 | value: FOLLOW_WORDS[text(n["follow"])] ?? "off", | |
| 202 | ...(web(origin) && slugOf(repo) ? { link: `${origin}/${repo}` } : {}), | |
| 203 | }); | |
| 204 | } | |
| 205 | const left = nodes.length - FORK_ROWS; | |
| 206 | if (left > 0) rows.push({ label: "More", value: `${left} more not shown` }); | |
| 207 | if (v["truncated"] === true) rows.push({ label: "Truncated", value: "more forks exist than the tool returned; ask with a larger limit or a smaller depth" }); | |
| 208 | return { kind, title: CARD_TITLE[kind], status: `${nodes.length} repositor${nodes.length === 1 ? "y" : "ies"} · from ${text(v["root"])}`, rows, actions }; | |
| 209 | } | |
| 177 | 210 | if (kind === "crew") { |
| 178 | 211 | // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`. |
| 179 | 212 | const v = obj(value); |
| @@ -485,6 +518,7 @@ function draw(card) { | ||
| 485 | 518 | const dl = document.createElement("dl"); |
| 486 | 519 | for (const r of card.rows) { |
| 487 | 520 | const dt = el("dt", r.label); |
| 521 | if (r.depth > 0) dt.style.paddingInlineStart = Math.min(8, r.depth) * 1.25 + "em"; | |
| 488 | 522 | if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); } |
| 489 | 523 | dl.append(dt, el("dd", r.value)); |
| 490 | 524 | } |
test/mcp-forks-card.test.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** MCP `fork_tree` draws the `ui://forge/forks` card: a row per node, indented by depth, with how it follows; unreadable forks absent. */ | |
| 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 { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { CARD_OF_TOOL, SHELL_JS, cardFor } from "../src/web/studio/cards.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 18 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 19 | return r.value; | |
| 20 | }; | |
| 21 | ||
| 22 | describe("MCP fork_tree card", () => { | |
| 23 | let dataDir = ""; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let token = ""; | |
| 27 | let bobToken = ""; | |
| 28 | ||
| 29 | const call = async (args: unknown, as = token) => { | |
| 30 | const res = await fetch(`${servers.url}/mcp`, { | |
| 31 | method: "POST", | |
| 32 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${as}` }, | |
| 33 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "fork_tree", arguments: args } }), | |
| 34 | }); | |
| 35 | return ((await res.json()) as any).result; | |
| 36 | }; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-forks-card-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | const users: Record<string, User> = {}; | |
| 43 | for (const name of ["alice", "bob", "dan", "fay"]) | |
| 44 | users[name] = must(await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@f.dev`, password: "a-good-passphrase", adminIfFirst: name === "alice" })); | |
| 45 | for (const u of Object.values(users)) promote(app.db, u); | |
| 46 | token = createToken(app.db, users["alice"]!.id, "admin", "admin").token; | |
| 47 | bobToken = createToken(app.db, users["bob"]!.id, "bob", "ro").token; | |
| 48 | const upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" })); | |
| 49 | const bob = must(await forkRepo(app, users["bob"]!, upstream)); | |
| 50 | must(await forkRepo(app, users["dan"]!, bob)); | |
| 51 | // A private fork alice cannot read (only its owner reads it). | |
| 52 | must(await forkRepo(app, users["fay"]!, upstream, "secret", { visibility: "private" })); | |
| 53 | }, 60_000); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the fork_tree tool draws the forks card", () => { | |
| 62 | expect(CARD_OF_TOOL["fork_tree"]).toBe("forks"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a three-level tree is rows indented by depth, linked, with the follow words", async () => { | |
| 66 | const result = await call({ repo: "alice/app" }); | |
| 67 | const json = JSON.parse(result.content[0].text); | |
| 68 | const card = result.structuredContent.card; | |
| 69 | expect(card.kind).toBe("forks"); | |
| 70 | const rows = card.rows.filter((r: any) => r.label.trim().endsWith("/app")); | |
| 71 | expect(rows.map((r: any) => r.label)).toEqual(["alice/app", "bob/app", "dan/app"]); | |
| 72 | // Depth is data, and the shell draws it as padding (leading spaces would collapse). | |
| 73 | expect(rows.map((r: any) => r.depth ?? 0)).toEqual([0, 1, 2]); | |
| 74 | expect(SHELL_JS).toContain("paddingInlineStart"); | |
| 75 | expect(rows[0].value).toBe("follows on its own"); | |
| 76 | expect(rows[1].value).toBe("offers updates"); | |
| 77 | expect(rows[2].link).toMatch(/\/dan\/app$/); | |
| 78 | expect(json.nodes.map((n: any) => n.repo)).toContain("dan/app"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a repository the caller cannot read is absent", async () => { | |
| 82 | // The admin reads the private fork: the card names it. Bob cannot read it: neither the card nor the JSON does. | |
| 83 | expect(JSON.stringify((await call({ repo: "alice/app" })).structuredContent.card)).toContain("fay/secret"); | |
| 84 | const result = await call({ repo: "alice/app" }, bobToken); | |
| 85 | expect(result.content[0].text).not.toContain("fay/"); | |
| 86 | expect(JSON.stringify(result.structuredContent.card)).not.toContain("fay/"); | |
| 87 | expect(result.structuredContent.card.rows.length).toBeGreaterThan(0); | |
| 88 | }); | |
| 89 | ||
| 90 | test("truncated and over-long trees say what was left out; an empty answer has no card", () => { | |
| 91 | const nodes = Array.from({ length: 33 }, (_, i) => ({ repo: `u${i}/app`, parent: null, depth: 1, follow: "dont-follow" })); | |
| 92 | const card = cardFor("fork_tree", { root: "u/app", nodes, truncated: true })!; | |
| 93 | expect(card.rows.find((r) => r.label === "More")!.value).toBe("3 more not shown"); | |
| 94 | expect(card.rows.some((r) => r.label === "Truncated")).toBe(true); | |
| 95 | expect(card.rows[0]!.value).toBe("off"); | |
| 96 | expect(cardFor("fork_tree", { root: null, nodes: [], truncated: false })).toBeUndefined(); | |
| 97 | }); | |
| 98 | }); |
test/mcp.test.ts +1
| @@ -1293,6 +1293,7 @@ describe("the forge over MCP", () => { | ||
| 1293 | 1293 | "ui://forge/explore", |
| 1294 | 1294 | "ui://forge/feed", |
| 1295 | 1295 | "ui://forge/flow", |
| 1296 | "ui://forge/forks", | |
| 1296 | 1297 | "ui://forge/issue", |
| 1297 | 1298 | "ui://forge/map", |
| 1298 | 1299 | "ui://forge/pull", |
Principle 2 (show the real rows): leading-space indentation collapses in HTML, so depth is not visible in the card. Carry depth as a real field and style it, or make the label render whitespace; add a test that would fail on flat rendering.