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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11407-mcp-forges-draws-a-ui-forge-forges-card → main 6 commits fork 2 threads · 2 unresolved
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`; | ||
| 112 | 112 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 113 | 113 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 114 | 114 | | `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 | | |
| 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 | 118 | | `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 | ||
| 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. | |
| 532 | 532 | | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. | |
| 533 | 533 | | `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. | | |
| 534 | 535 | | `ui://forge/held` | `held` | What waits on a person: themes, one row each, and one button that closes the done ones. | |
| 535 | 536 | |
| 536 | 537 | 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"; | ||
| 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" | "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"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 44 | 44 | |
| 45 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 46 | 46 | 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"]; | |
| 48 | 48 | |
| 49 | 49 | /** The tool whose answer each card draws. */ |
| 50 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -70,6 +70,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 70 | 70 | fork_tree: "forks", |
| 71 | 71 | search: "search", |
| 72 | 72 | issues: "issues", |
| 73 | forges: "forges", | |
| 73 | 74 | held: "held", |
| 74 | 75 | }; |
| 75 | 76 | |
| @@ -96,6 +97,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 96 | 97 | releases: "Shipped", |
| 97 | 98 | search: "Search", |
| 98 | 99 | issues: "Issues", |
| 100 | forges: "Forges you sent", | |
| 99 | 101 | held: "Later", |
| 100 | 102 | }; |
| 101 | 103 | |
| @@ -129,6 +131,8 @@ const SAYS_WORDS = 160; | ||
| 129 | 131 | const BOARD_PANELS = 8; |
| 130 | 132 | /** A forks card shows this many nodes, then counts the rest. */ |
| 131 | 133 | const FORK_ROWS = 30; |
| 134 | /** A sent-forges card shows this many rows, then counts the rest. */ | |
| 135 | const SENT_ROWS = 30; | |
| 132 | 136 | /** How a fork follows its parent, in plain words. */ |
| 133 | 137 | const FOLLOW_WORDS: Readonly<Record<string, string>> = { |
| 134 | 138 | autonomous: "follows on its own", |
| @@ -282,6 +286,28 @@ export function cardFor( | ||
| 282 | 286 | 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" }); |
| 283 | 287 | return { kind, title: CARD_TITLE[kind], status: `${nodes.length} repositor${nodes.length === 1 ? "y" : "ies"} · from ${text(v["root"])}`, rows, actions }; |
| 284 | 288 | } |
| 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 | } | |
| 285 | 311 | if (tool === "asks_act") { |
| 286 | 312 | // Only a done bulk action has a card: its receipt, and the undo that takes it back. |
| 287 | 313 | // A plan (`dryRun`) or a `listen` has no `receipt`. |
test/client-list.test.ts +1 −1
| @@ -179,7 +179,7 @@ describe("the client list", () => { | ||
| 179 | 179 | const maria = JSON.parse(rows.result.content[0].text).forges.find((f: any) => f.name === "maria"); |
| 180 | 180 | expect(typeof maria.lastActivityAt).toBe("string"); |
| 181 | 181 | // 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); | |
| 183 | 183 | }); |
| 184 | 184 | |
| 185 | 185 | 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 |
||
| 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", () => { | ||
| 1294 | 1294 | "ui://forge/explore", |
| 1295 | 1295 | "ui://forge/feed", |
| 1296 | 1296 | "ui://forge/flow", |
| 1297 | "ui://forge/forges", | |
| 1297 | 1298 | "ui://forge/forks", |
| 1298 | 1299 | "ui://forge/held", |
| 1299 | 1300 | "ui://forge/issue", |
new above
offline
The issue asks for kind
forges(ui://forge/forges);sentis a different name. Rename everywhere.