Public: anyone can read this; the crew can change it.
MCP `explore` answers with a `ui://forge/explore` card: the top ten rows, each with its numbers and a link #5926
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5904-mcp-explore-answers-with-a-ui-forge-expl → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +76
−4
src/web/studio/cards.ts +35 −4
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull"; | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "explore"; | |
| 14 | 14 | |
| 15 | 15 | export interface CardRow { |
| 16 | 16 | readonly label: string; |
| @@ -34,9 +34,12 @@ export interface Card { | ||
| 34 | 34 | readonly actions: readonly CardAction[]; |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | /** Only a rooted path on this forge: "//host" or another forge's address would leave it. */ | |
| 38 | const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!\/)/.test(path); | |
| 39 | ||
| 37 | 40 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 38 | 41 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 39 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull"]; | |
| 42 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "explore"]; | |
| 40 | 43 | |
| 41 | 44 | /** The tool whose answer each card draws. */ |
| 42 | 45 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -46,6 +49,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 46 | 49 | milestones: "wave", |
| 47 | 50 | map: "map", |
| 48 | 51 | feed: "feed", |
| 52 | explore: "explore", | |
| 49 | 53 | new_app: "app", |
| 50 | 54 | pull: "pull", |
| 51 | 55 | }; |
| @@ -57,6 +61,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 57 | 61 | wave: "Wave", |
| 58 | 62 | map: "Plan", |
| 59 | 63 | feed: "Feed", |
| 64 | explore: "Explore", | |
| 60 | 65 | pull: "Pull", |
| 61 | 66 | }; |
| 62 | 67 | |
| @@ -215,10 +220,36 @@ export function cardFor( | ||
| 215 | 220 | rows.push({ |
| 216 | 221 | label: `${who === "" ? "" : `${who}: `}${text(i["what"])}`, |
| 217 | 222 | value: [text(i["app"]), timeAgo(text(i["at"]))].filter((x) => x !== "").join(" · "), |
| 218 | // Only a rooted path on this forge: "//host" would leave it. | |
| 219 | ...(origin !== "" && /^\/(?!\/)/.test(link) ? { link: origin + link } : {}), | |
| 223 | ...(here(origin, link) ? { link: origin + link } : {}), | |
| 220 | 224 | }); |
| 221 | 225 | } |
| 226 | } else if (kind === "explore") { | |
| 227 | const items = list(obj(value)["items"]).map(obj); | |
| 228 | const query = text(obj(value)["query"]); | |
| 229 | status = `${items.length} ranked${query === "" ? "" : ` · ${query}`}`; | |
| 230 | for (const i of items.slice(0, FEED_LIMIT)) { | |
| 231 | const n = obj(i["numbers"]); | |
| 232 | const link = text(i["url"]); | |
| 233 | const kindOf = text(i["kind"]); | |
| 234 | rows.push({ | |
| 235 | label: `${kindOf} · ${text(i["name"])}`, | |
| 236 | value: | |
| 237 | kindOf === "forge" | |
| 238 | ? text(i["description"]) | |
| 239 | : `${text(n["forks"])} forks · ${text(n["ships"])} ships · ${text(n["awakeHours"])} h awake${ | |
| 240 | kindOf === "person" ? ` · ${text(n["apps"])} apps` : "" | |
| 241 | }`, | |
| 242 | ...(here(origin, link) ? { link: origin + link } : {}), | |
| 243 | }); | |
| 244 | const repo = obj(i["repo"]); | |
| 245 | const slug = `${text(repo["owner"])}/${text(repo["name"])}`; | |
| 246 | if (kindOf === "app" && i["copyable"] === true && origin !== "" && /^[^/]+\/[^/]+$/.test(slug)) | |
| 247 | rows.push({ | |
| 248 | label: "Make it yours", | |
| 249 | value: text(i["name"]), | |
| 250 | link: `${origin}/${slug}/make-it-yours`, | |
| 251 | }); | |
| 252 | } | |
| 222 | 253 | } else { |
| 223 | 254 | const limbs = list(obj(value)["limbs"]).map(obj); |
| 224 | 255 | const repo = obj(obj(value)["repo"]); |
test/explore-card.test.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** The explore card: the top ten rows with their numbers, linked only to this forge, escaped by the shared shell. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { CARD_OF_TOOL, cardFor } from "../src/web/studio/cards.ts"; | |
| 4 | ||
| 5 | const numbers = { forks: 1, ships: 2, awakeHours: 3, apps: 0 }; | |
| 6 | const many = Array.from({ length: 12 }, (_, n) => ({ | |
| 7 | kind: "app", | |
| 8 | name: n === 0 ? "<img src=x onerror=alert(1)>" : `app${n}`, | |
| 9 | url: `/bob/app${n}`, | |
| 10 | description: "", | |
| 11 | numbers, | |
| 12 | repo: { owner: "bob", name: `app${n}` }, | |
| 13 | copyable: n === 0, | |
| 14 | })); | |
| 15 | ||
| 16 | test("the explore tool draws the explore card", () => { | |
| 17 | expect(CARD_OF_TOOL["explore"]).toBe("explore"); | |
| 18 | }); | |
| 19 | ||
| 20 | test("the card keeps ten rows, with numbers and links, and Make it yours beside the app", () => { | |
| 21 | const c = cardFor("explore", { query: "", items: many }, "https://forge.test")!; | |
| 22 | expect(c.rows.filter((r) => r.label !== "Make it yours")).toHaveLength(10); | |
| 23 | expect(c.rows[0]).toEqual({ | |
| 24 | label: "app · <img src=x onerror=alert(1)>", | |
| 25 | value: "1 forks · 2 ships · 3 h awake", | |
| 26 | link: "https://forge.test/bob/app0", | |
| 27 | }); | |
| 28 | expect(c.rows[1]!.link).toBe("https://forge.test/bob/app0/make-it-yours"); | |
| 29 | expect(c.rows.filter((r) => r.label === "Make it yours")).toHaveLength(1); | |
| 30 | }); | |
| 31 | ||
| 32 | test("only a rooted path on this forge becomes a link", () => { | |
| 33 | const away = cardFor( | |
| 34 | "explore", | |
| 35 | { items: [{ kind: "forge", name: "x", url: "https://evil.test/" }, { kind: "app", name: "y", url: "//evil.test" }] }, | |
| 36 | "https://forge.test", | |
| 37 | )!; | |
| 38 | expect(away.rows.every((r) => r.link === undefined)).toBe(true); | |
| 39 | }); |
test/mcp.test.ts +2
| @@ -1162,6 +1162,7 @@ describe("the forge over MCP", () => { | ||
| 1162 | 1162 | expect(uris).toEqual([ |
| 1163 | 1163 | "ui://forge/app", |
| 1164 | 1164 | "ui://forge/decision", |
| 1165 | "ui://forge/explore", | |
| 1165 | 1166 | "ui://forge/feed", |
| 1166 | 1167 | "ui://forge/map", |
| 1167 | 1168 | "ui://forge/pull", |
| @@ -1184,6 +1185,7 @@ describe("the forge over MCP", () => { | ||
| 1184 | 1185 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| 1185 | 1186 | expect(meta("map")).toBe("ui://forge/map"); |
| 1186 | 1187 | expect(meta("feed")).toBe("ui://forge/feed"); |
| 1188 | expect(meta("explore")).toBe("ui://forge/explore"); | |
| 1187 | 1189 | expect(meta("pull")).toBe("ui://forge/pull"); |
| 1188 | 1190 | }); |
| 1189 | 1191 |
new above
offline
Minor, no change needed: a forge row's
urlis another forge's absolute address, soheredrops its link and the row shows only the description. That is safe and matches the feed card. If the forge rows should link out later, use an explicit allow-list of known forges rather than looseninghere.