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

Merged opened this

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

Merged

scout/forge:scout/5904-mcp-explore-answers-with-a-ui-forge-expl → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/5904-mcp-explore-answers-with-a-ui-forge-expl was merged into main as a10ad19.

Review

Sign in to review this change.
3 files changed +76 −4
src/web/studio/cards.ts +35 −4
@@ -10,7 +10,7 @@
1010 import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212
13 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull";
13 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "explore";
1414
1515 export interface CardRow {
1616 readonly label: string;
@@ -34,9 +34,12 @@ export interface Card {
3434 readonly actions: readonly CardAction[];
3535 }
3636
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
3740 export const CARD_MIME = "text/html;profile=mcp-app";
3841 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"];
4043
4144 /** The tool whose answer each card draws. */
4245 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -46,6 +49,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4649 milestones: "wave",
4750 map: "map",
4851 feed: "feed",
52 explore: "explore",
4953 new_app: "app",
5054 pull: "pull",
5155 };
@@ -57,6 +61,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
5761 wave: "Wave",
5862 map: "Plan",
5963 feed: "Feed",
64 explore: "Explore",
6065 pull: "Pull",
6166 };
6267
@@ -215,10 +220,36 @@ export function cardFor(
215220 rows.push({
216221 label: `${who === "" ? "" : `${who}: `}${text(i["what"])}`,
217222 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 } : {}),
220224 });
221225 }
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${

Minor, no change needed: a forge row's url is another forge's absolute address, so here drops 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 loosening here.

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 }
222253 } else {
223254 const limbs = list(obj(value)["limbs"]).map(obj);
224255 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", () => {
11621162 expect(uris).toEqual([
11631163 "ui://forge/app",
11641164 "ui://forge/decision",
1165 "ui://forge/explore",
11651166 "ui://forge/feed",
11661167 "ui://forge/map",
11671168 "ui://forge/pull",
@@ -1184,6 +1185,7 @@ describe("the forge over MCP", () => {
11841185 expect(meta("milestones")).toBe("ui://forge/wave");
11851186 expect(meta("map")).toBe("ui://forge/map");
11861187 expect(meta("feed")).toBe("ui://forge/feed");
1188 expect(meta("explore")).toBe("ui://forge/explore");
11871189 expect(meta("pull")).toBe("ui://forge/pull");
11881190 });
11891191

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs