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

Merged opened this

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

Merged

scout/forge:scout/11031-the-mcp-fork-tree-tool-answers-with-a-ui → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/11031-the-mcp-fork-tree-tool-answers-with-a-ui was merged into main as 58e722b.

Review

Sign in to review this change.
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`;
114114 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
115115 | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, stage, resend, spendTodayUsd, ceilingUsd, size, use | 231 |
116116 | `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 |
118118 | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 |
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 |
@@ -491,6 +491,7 @@ answers
491491 The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a
492492 linked production row when the app has an http(s) URL; the `new_app` card links its
493493 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`.
494495 `resources/list` and `resources/read` serve one small
495496 sandboxed page, which draws the `structuredContent.card` model that the
496497 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";
44 /** The whole tree a repo sits in, as the caller may read it. */
55 export const tool: Tool = {
66 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",
88 description: "The fork tree around a repo: ancestors, siblings and descendants you may read.",
99 inputSchema: {
1010 type: "object",
src/web/studio/cards.ts +36 −2
@@ -11,13 +11,15 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
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";
1515
1616 export interface CardRow {
1717 readonly label: string;
1818 readonly value: string;
1919 /** An absolute http(s) address the row's label links to. */
2020 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;
2123 }
2224 export interface CardAction {
2325 readonly label: string;
@@ -40,7 +42,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4042
4143 export const CARD_MIME = "text/html;profile=mcp-app";
4244 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"];
4446
4547 /** The tool whose answer each card draws. */
4648 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -61,6 +63,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
6163 flow: "flow",
6264 scorecard: "crew",
6365 boards: "board",
66 fork_tree: "forks",
6467 };
6568
6669 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -78,6 +81,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
7881 flow: "Flow",
7982 crew: "Crew",
8083 board: "Board",
84 forks: "Forks",
8185 };
8286
8387 type Obj = Record<string, unknown>;
@@ -93,6 +97,15 @@ const ASKED_WORDS = 80;
9397 const ASKED_LIMIT = 20;
9498 /** A board card shows this many panels, then counts the rest. */
9599 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 };
96109 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
97110 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
98111
@@ -174,6 +187,26 @@ export function cardFor(
174187 if (panels.length > BOARD_PANELS) rows.push({ label: "More", value: `${panels.length - BOARD_PANELS} more` });
175188 return { kind, title: text(v["title"]), status: `${panels.length} panel${panels.length === 1 ? "" : "s"} · ${text(v["range"])}`, rows, actions };
176189 }
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)));
outdated

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.

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 }
177210 if (kind === "crew") {
178211 // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`.
179212 const v = obj(value);
@@ -485,6 +518,7 @@ function draw(card) {
485518 const dl = document.createElement("dl");
486519 for (const r of card.rows) {
487520 const dt = el("dt", r.label);
521 if (r.depth > 0) dt.style.paddingInlineStart = Math.min(8, r.depth) * 1.25 + "em";
488522 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); }
489523 dl.append(dt, el("dd", r.value));
490524 }
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", () => {
12931293 "ui://forge/explore",
12941294 "ui://forge/feed",
12951295 "ui://forge/flow",
1296 "ui://forge/forks",
12961297 "ui://forge/issue",
12971298 "ui://forge/map",
12981299 "ui://forge/pull",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs