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

MCP `forges` draws a `ui://forge/forges` card: one row per forge sent, its stage in plain words, linked #11444

Merged opened this

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

Merged

scout/forge:scout/11407-mcp-forges-draws-a-ui-forge-forges-card → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/11407-mcp-forges-draws-a-ui-forge-forges-card was merged into main as 97d8f10.

Review

Sign in to review this change.
5 files changed +141 −4
docs/MCP.md +2 −1
@@ -112,7 +112,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
112112 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
113113 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
114114 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
115 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 336 |
115 | `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 |
118118 | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 387 |
@@ -531,6 +531,7 @@ answers
531531 | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. |
532532 | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. |
533533 | `ui://forge/releases` | `releases` with `shipped` | What shipped: one linked row per landing, the live version, what is on its way. |
534 | `ui://forge/forges` | `forges` (listing, admin) | Forges you sent: one linked row each, whom it is for and its stage in words (sent, opened, signed in, last tell); a link unopened for 3 days says `resend` and comes first. |
534535 | `ui://forge/held` | `held` | What waits on a person: themes, one row each, and one button that closes the done ones. |
535536
536537 The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with
src/web/studio/cards.ts +28 −2
@@ -11,7 +11,7 @@ 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" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "held";
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4444
4545 export const CARD_MIME = "text/html;profile=mcp-app";
4646 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held"];
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges"];
4848
4949 /** The tool whose answer each card draws. */
5050 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -70,6 +70,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
7070 fork_tree: "forks",
7171 search: "search",
7272 issues: "issues",
73 forges: "forges",
outdated

The issue asks for kind forges (ui://forge/forges); sent is a different name. Rename everywhere.

7374 held: "held",
7475 };
7576
@@ -96,6 +97,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
9697 releases: "Shipped",
9798 search: "Search",
9899 issues: "Issues",
100 forges: "Forges you sent",
99101 held: "Later",
100102 };
101103
@@ -129,6 +131,8 @@ const SAYS_WORDS = 160;
129131 const BOARD_PANELS = 8;
130132 /** A forks card shows this many nodes, then counts the rest. */
131133 const FORK_ROWS = 30;
134 /** A sent-forges card shows this many rows, then counts the rest. */
135 const SENT_ROWS = 30;
132136 /** How a fork follows its parent, in plain words. */
133137 const FOLLOW_WORDS: Readonly<Record<string, string>> = {
134138 autonomous: "follows on its own",
@@ -282,6 +286,28 @@ export function cardFor(
282286 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" });
283287 return { kind, title: CARD_TITLE[kind], status: `${nodes.length} repositor${nodes.length === 1 ? "y" : "ies"} · from ${text(v["root"])}`, rows, actions };
284288 }
289 if (kind === "forges") {
290 // `forges` lists `{ says, forges: [clientRows] }`; a plan, a code or a refusal has no `forges` array, so no card.
291 const v = obj(value);
292 if (!Array.isArray(v["forges"])) return undefined;
293 const all = v["forges"].map(obj);
294 const word = (f: Obj): string => {
295 const stage = obj(f["stage"]);
296 return stage["resend"] === true ? "resend" : text(stage["words"]).toLowerCase() || "being made";
297 };
298 // Unopened-for-days links first; the rest keep the server's order.
299 const ordered = [...all.filter((f) => obj(f["stage"])["resend"] === true), ...all.filter((f) => obj(f["stage"])["resend"] !== true)];
300 for (const f of ordered.slice(0, SENT_ROWS)) {
301 const owner = text(f["for"]);
302 rows.push({
303 label: text(f["name"]),
304 value: `${owner === "" ? "no one named" : `for ${owner}`} · ${word(f)}`,
305 ...(web(text(f["url"])) ? { link: text(f["url"]) } : {}),
306 });
307 }
308 if (all.length > SENT_ROWS) rows.push({ label: "More", value: `${all.length - SENT_ROWS} more not shown` });
309 return { kind, title: CARD_TITLE[kind], status: `${all.length} forge${all.length === 1 ? "" : "s"} sent`, rows, actions };
310 }
285311 if (tool === "asks_act") {
286312 // Only a done bulk action has a card: its receipt, and the undo that takes it back.
287313 // A plan (`dryRun`) or a `listen` has no `receipt`.
test/client-list.test.ts +1 −1
@@ -179,7 +179,7 @@ describe("the client list", () => {
179179 const maria = JSON.parse(rows.result.content[0].text).forges.find((f: any) => f.name === "maria");
180180 expect(typeof maria.lastActivityAt).toBe("string");
181181 // The secret itself is never in an answer.
182 expect(JSON.stringify(rows)).not.toMatch(/status|secret/i);
182 expect(rows.result.content[0].text).not.toMatch(/status|secret/i);
183183 });
184184
185185 test("`for` is at most 200 characters", async () => {
test/mcp-forges-card.test.ts added +109
@@ -0,0 +1,109 @@
1 /** MCP `forges` draws the `ui://forge/forges` card: a row per forge sent, resend first, text kept as plain data. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdirSync, writeFileSync } from "node:fs";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } 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 { useChildFetch } from "../src/spawn/clients.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 const row = (name: string, url: string, who: string | null, stage: { words: string; resend: boolean } | null) => ({ name, url, for: who, stage });
18
19 describe("MCP forges card", () => {
20 test("forges maps to a card titled Forges you sent", () => {
21 expect(CARD_OF_TOOL["forges"]).toBe("forges");
22 expect(cardFor("forges", { forges: [] })?.title).toBe("Forges you sent");
23 });
24
25 test("one row per forge, resend first, stage in words, name linked", () => {
26 const card = cardFor("forges", {
27 forges: [
28 row("alpha", "https://alpha.example", "Ann", { words: "Last tell", resend: false }),
29 row("beta", "https://beta.example", "Bob", { words: "Not opened in 3 days", resend: true }),
30 row("gamma", "https://gamma.example", null, { words: "Signed in", resend: false }),
31 row("delta", "https://delta.example", "Dee", null),
32 ],
33 })!;
34 expect(card.status).toBe("4 forges sent");
35 expect(card.rows.map((r) => r.label)).toEqual(["beta", "alpha", "gamma", "delta"]);
36 expect(card.rows[0]).toEqual({ label: "beta", value: "for Bob · resend", link: "https://beta.example" });
37 expect(card.rows[1]!.value).toBe("for Ann · last tell");
38 expect(card.rows[2]!.value).toBe("no one named · signed in");
39 expect(card.rows[3]!.value).toBe("for Dee · being made");
40 expect(cardFor("forges", { forges: [row("a", "https://a.example", "x", null)] })!.status).toBe("1 forge sent");
41 });
42
43 test("text stays plain data and only http(s) addresses link", () => {
44 const card = cardFor("forges", { forges: [row("<img src=x onerror=1>", "javascript:alert(1)", "<b>me</b>", { words: "Sent", resend: false })] })!;
45 expect(card.rows[0]!.label).toBe("<img src=x onerror=1>");
46 expect(card.rows[0]!.link).toBeUndefined();
47 expect(SHELL_JS).not.toContain("innerHTML");
48 });
49
50 test("a plan or code answer has no card", () => {
51 expect(cardFor("forges", { plan: "small" })).toBeUndefined();
52 });
53
54 describe("over MCP", () => {
55 let dataDir = "";
56 let app: App;
57 let servers: Servers;
58 let adminToken = "";
59 let bobToken = "";
60 const call = async (as: string) => {
61 const res = await fetch(`${servers.url}/mcp`, {
62 method: "POST",
63 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${as}` },
64 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forges", arguments: {} } }),
65 });
66 return ((await res.json()) as any).result;
67 };
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-forges-card-"));
70 app = createApp({ dataDir, port: 0 });
71 const DAY = 86_400_000;
72 mkdirSync(join(dataDir, "spawn"), { recursive: true });
73 const send = (name: string, who: string, daysAgo: number) =>
74 writeFileSync(join(dataDir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId: `cell-${name}`, done: true, placement: "hosted", size: "small", apps: [], forWhom: who, createdAt: new Date(Date.now() - daysAgo * DAY).toISOString() }));
75 send("alpha", "Ann", 1);
76 send("beta", "Bob", 4);
77 useChildFetch(app, async () => Response.json({ claimed: false, link: { state: "open", revokedAt: null, usedAt: null } }));
78 servers = await serve(app);
79 const make = async (n: string) => {
80 const r = await createUser(app.db, { origin: "seed", trusted: true, username: n, email: `${n}@f.dev`, password: "a-good-passphrase", adminIfFirst: n === "alice" });
81 if (!r.ok) throw new Error("user");
82 return r.value;
83 };
84 const alice = await make("alice");
85 const bob = await make("bob");
86 adminToken = createToken(app.db, alice.id, "admin", "admin").token;
87 bobToken = createToken(app.db, bob.id, "bob", "ro").token;
outdated

Only the empty listing is proven over MCP. Send a real forge here and assert the row, its for and the resend-first order from the real clientRows answer.

88 }, 60_000);
89 afterAll(async () => {
90 servers.stop();
91 app.close();
92 await rm(dataDir, { recursive: true, force: true });
93 });
94
95 test("an admin's listing carries the card with the real forges, resend first; a non-admin is forbidden with none", async () => {
96 const ok = await call(adminToken);
97 expect(ok.structuredContent.card.kind).toBe("forges");
98 expect(ok.structuredContent.card.status).toBe("2 forges sent");
99 const rows = ok.structuredContent.card.rows;
100 expect(rows.map((r: any) => r.label)).toEqual(["beta", "alpha"]);
101 expect(rows[0].value).toBe("for Bob · resend");
102 expect(rows[1].value).toBe("for Ann · sent");
103 const no = await call(bobToken);
104 expect(no.isError).toBe(true);
105 expect(no.structuredContent?.card).toBeUndefined();
106 expect(JSON.stringify(no)).toContain("forbidden");
107 });
108 });
109 });
test/mcp.test.ts +1
@@ -1294,6 +1294,7 @@ describe("the forge over MCP", () => {
12941294 "ui://forge/explore",
12951295 "ui://forge/feed",
12961296 "ui://forge/flow",
1297 "ui://forge/forges",
12971298 "ui://forge/forks",
12981299 "ui://forge/held",
12991300 "ui://forge/issue",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs