Public: anyone can read this; the crew can change it.
The `feed` tool's answer draws a ui://forge/feed card: the latest ten items, each a line and a link #3363
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3348-the-feed-tool-s-answer-draws-a-ui-forge → main 5 commits fork 2 threads · 2 unresolved
5 files
changed +80
−10
docs/MCP.md +2 −2
| @@ -175,9 +175,9 @@ answers | ||
| 175 | 175 | |
| 176 | 176 | ## Cards, the contract and the plugins (#1116) |
| 177 | 177 | |
| 178 | - **Cards.** `apps`, `new_app`, `now`, `asks` and `milestones` name a `ui://` resource in | |
| 178 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones` and `feed` name a `ui://` resource in | |
| 179 | 179 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 180 | `decision`, `wave`. `resources/list` and `resources/read` serve one small | |
| 180 | `decision`, `wave`, `feed`. `resources/list` and `resources/read` serve one small | |
| 181 | 181 | sandboxed page, which draws the `structuredContent.card` model that the |
| 182 | 182 | tool result carries, with `textContent` only. The model is built in |
| 183 | 183 | `src/web/studio/cards.ts`. A card's |
src/http/mcp.ts +3 −3
| @@ -3511,8 +3511,8 @@ function adminNote(ctx: Ctx, user: User): string { | ||
| 3511 | 3511 | } |
| 3512 | 3512 | |
| 3513 | 3513 | /** A card's model beside the text, for a host that draws `ui://` resources. */ |
| 3514 | function cardResult(name: string, value: unknown): Record<string, unknown> { | |
| 3515 | const card = cardFor(name, value); | |
| 3514 | function cardResult(name: string, value: unknown, origin: string): Record<string, unknown> { | |
| 3515 | const card = cardFor(name, value, origin); | |
| 3516 | 3516 | return card === undefined ? {} : { structuredContent: { card } }; |
| 3517 | 3517 | } |
| 3518 | 3518 | |
| @@ -3612,7 +3612,7 @@ function rpcOne( | ||
| 3612 | 3612 | answer.ok |
| 3613 | 3613 | ? { |
| 3614 | 3614 | content: [{ type: "text", text: JSON.stringify(answer.value) }], |
| 3615 | ...cardResult(String(params["name"]), answer.value), | |
| 3615 | ...cardResult(String(params["name"]), answer.value, ctx.url.origin), | |
| 3616 | 3616 | } |
| 3617 | 3617 | : { |
| 3618 | 3618 | content: [ |
src/web/studio/cards.ts +27 −4
| @@ -8,12 +8,15 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | import { timeAgo } from "../ui.ts"; | |
| 11 | 12 | |
| 12 | export type CardKind = "app" | "run" | "decision" | "wave"; | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "feed"; | |
| 13 | 14 | |
| 14 | 15 | export interface CardRow { |
| 15 | 16 | readonly label: string; |
| 16 | 17 | readonly value: string; |
| 18 | /** An absolute http(s) address the row's label links to. */ | |
| 19 | readonly link?: string; | |
| 17 | 20 | } |
| 18 | 21 | export interface CardAction { |
| 19 | 22 | readonly label: string; |
| @@ -33,7 +36,7 @@ export interface Card { | ||
| 33 | 36 | |
| 34 | 37 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 35 | 38 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 36 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave"]; | |
| 39 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "feed"]; | |
| 37 | 40 | |
| 38 | 41 | /** The tool whose answer each card draws. */ |
| 39 | 42 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -41,6 +44,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 41 | 44 | now: "run", |
| 42 | 45 | asks: "decision", |
| 43 | 46 | milestones: "wave", |
| 47 | feed: "feed", | |
| 44 | 48 | new_app: "app", |
| 45 | 49 | }; |
| 46 | 50 | |
| @@ -49,6 +53,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 49 | 53 | run: "Working now", |
| 50 | 54 | decision: "Waiting on you", |
| 51 | 55 | wave: "Wave", |
| 56 | feed: "Feed", | |
| 52 | 57 | }; |
| 53 | 58 | |
| 54 | 59 | type Obj = Record<string, unknown>; |
| @@ -58,9 +63,10 @@ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); | ||
| 58 | 63 | const text = (v: unknown): string => |
| 59 | 64 | typeof v === "string" ? v : typeof v === "number" ? String(v) : ""; |
| 60 | 65 | const LIMIT = 12; |
| 66 | const FEED_LIMIT = 10; | |
| 61 | 67 | |
| 62 | 68 | /** The card for a tool's answer, or nothing when the answer has no card. */ |
| 63 | export function cardFor(tool: string, value: unknown): Card | undefined { | |
| 69 | export function cardFor(tool: string, value: unknown, origin = ""): Card | undefined { | |
| 64 | 70 | const kind = CARD_OF_TOOL[tool]; |
| 65 | 71 | if (kind === undefined) return undefined; |
| 66 | 72 | const rows: CardRow[] = []; |
| @@ -130,6 +136,19 @@ export function cardFor(tool: string, value: unknown): Card | undefined { | ||
| 130 | 136 | if (repo !== "" && Number.isInteger(number) && number > 0) |
| 131 | 137 | actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true }); |
| 132 | 138 | } |
| 139 | } else if (kind === "feed") { | |
| 140 | const fresh = Number(obj(value)["new"]); | |
| 141 | status = Number.isInteger(fresh) && fresh > 0 ? `${fresh} new since you last looked` : "Latest"; | |
| 142 | for (const i of list(obj(value)["items"]).map(obj).slice(0, FEED_LIMIT)) { | |
| 143 | const link = text(i["link"]); | |
| 144 | const who = text(i["actor"]); | |
| 145 | rows.push({ | |
| 146 | label: `${who === "" ? "" : `${who}: `}${text(i["what"])}`, | |
| 147 | value: [text(i["app"]), timeAgo(text(i["at"]))].filter((x) => x !== "").join(" · "), | |
| 148 | // Only a rooted path on this forge: "//host" would leave it. | |
| 149 | ...(origin !== "" && /^\/(?!\/)/.test(link) ? { link: origin + link } : {}), | |
| 150 | }); | |
| 151 | } | |
| 133 | 152 | } else { |
| 134 | 153 | const limbs = list(obj(value)["limbs"]).map(obj); |
| 135 | 154 | const repo = obj(obj(value)["repo"]); |
| @@ -156,7 +175,11 @@ let n = 0; | ||
| 156 | 175 | function draw(card) { |
| 157 | 176 | root.replaceChildren(el("h2", card.title), el("p", card.status, "status")); |
| 158 | 177 | const dl = document.createElement("dl"); |
| 159 | for (const r of card.rows) dl.append(el("dt", r.label), el("dd", r.value)); | |
| 178 | for (const r of card.rows) { | |
| 179 | const dt = el("dt", r.label); | |
| 180 | if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); } | |
| 181 | dl.append(dt, el("dd", r.value)); | |
| 182 | } | |
| 160 | 183 | root.append(dl); |
| 161 | 184 | const field = card.actions.some((a) => a.words) ? document.createElement("input") : null; |
| 162 | 185 | if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); } |
test/card-shell.test.ts +21 −1
| @@ -6,7 +6,8 @@ class Node { | ||
| 6 | 6 | textContent = ""; |
| 7 | 7 | className = ""; |
| 8 | 8 | value = ""; |
| 9 | onclick: (() => void) | undefined; | |
| 9 | href = ""; | |
| 10 | onclick: ((ev?: any) => void) | undefined; | |
| 10 | 11 | focused = false; |
| 11 | 12 | constructor(readonly tag: string) {} |
| 12 | 13 | append(...n: Node[]) { this.children.push(...n); } |
| @@ -85,4 +86,23 @@ describe("the card shell", () => { | ||
| 85 | 86 | const card = cardFor("asks", { rows: [{ line: "a", kind: "paused", items: ["x"], repo: "a/b", words: {} }] })!; |
| 86 | 87 | expect(card.actions.some((a) => a.tool === "say")).toBe(false); |
| 87 | 88 | }); |
| 89 | ||
| 90 | test("a feed row with a forge link is an anchor that asks the host to open it; no link, or a javascript: one, stays text", () => { | |
| 91 | const s = shell(); | |
| 92 | const feed = cardFor("feed", { items: [{ what: "x", link: "/a/b/pulls/1" }, { what: "y" }] }, "https://forge.test")!; | |
| 93 | s.send(s.result({ ...feed, rows: [...feed.rows, { label: "z", value: "", link: "javascript:alert(1)" }] })); | |
| 94 | const dts = s.root.all().filter((n) => n.tag === "dt"); | |
| 95 | expect(dts).toHaveLength(3); | |
| 96 | const anchor = dts[0]!.children[0]!; | |
| 97 | expect(anchor.tag).toBe("a"); | |
| 98 | expect(anchor.href).toBe("https://forge.test/a/b/pulls/1"); | |
| 99 | expect(anchor.textContent).toBe("x"); | |
| 100 | let prevented = false; | |
| 101 | anchor.onclick!({ preventDefault: () => (prevented = true) }); | |
| 102 | expect(prevented).toBe(true); | |
| 103 | expect(s.posted[1]).toMatchObject({ method: "ui/open-link", params: { url: "https://forge.test/a/b/pulls/1" } }); | |
| 104 | expect(dts[1]!.children).toHaveLength(0); | |
| 105 | expect(dts[2]!.children).toHaveLength(0); | |
| 106 | expect(s.root.all().filter((n) => n.tag === "a")).toHaveLength(1); | |
| 107 | }); | |
| 88 | 108 | }); |
test/mcp.test.ts +27
| @@ -933,6 +933,7 @@ describe("the forge over MCP", () => { | ||
| 933 | 933 | expect(uris).toEqual([ |
| 934 | 934 | "ui://forge/app", |
| 935 | 935 | "ui://forge/decision", |
| 936 | "ui://forge/feed", | |
| 936 | 937 | "ui://forge/run", |
| 937 | 938 | "ui://forge/wave", |
| 938 | 939 | ]); |
| @@ -950,6 +951,32 @@ describe("the forge over MCP", () => { | ||
| 950 | 951 | expect(meta("now")).toBe("ui://forge/run"); |
| 951 | 952 | expect(meta("asks")).toBe("ui://forge/decision"); |
| 952 | 953 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| 954 | expect(meta("feed")).toBe("ui://forge/feed"); | |
| 955 | }); | |
| 956 | ||
| 957 | test("the feed card draws one linked row per item, ten at most, escaped as text", async () => { | |
| 958 | const { cardFor } = await import("../src/web/studio/cards.ts"); | |
| 959 | const items = Array.from({ length: 12 }, (_, n) => ({ | |
| 960 | actor: "scout", | |
| 961 | what: n === 0 ? "<b>shipped</b>" : `did ${n}`, | |
| 962 | app: "alice/game", | |
| 963 | at: new Date().toISOString(), | |
| 964 | link: `/alice/game/pulls/${n + 1}`, | |
| 965 | })); | |
| 966 | const card = cardFor("feed", { items }, "https://forge.test")!; | |
| 967 | expect(card.kind).toBe("feed"); | |
| 968 | expect(card.title).toBe("Feed"); | |
| 969 | expect(card.status).toBe("Latest"); | |
| 970 | expect(card.rows).toHaveLength(10); | |
| 971 | expect(card.rows[0]).toEqual({ | |
| 972 | label: "scout: <b>shipped</b>", | |
| 973 | value: "alice/game · just now", | |
| 974 | link: "https://forge.test/alice/game/pulls/1", | |
| 975 | }); | |
| 976 | expect(cardFor("feed", { items, new: 3 })!.status).toBe("3 new since you last looked"); | |
| 977 | expect(cardFor("feed", { items: [{ what: "x", link: "//evil.test/" }] }, "https://forge.test")!.rows[0]!.link).toBeUndefined(); | |
| 978 | const { body } = await call(rw, "tools/call", { name: "feed", arguments: {} }); | |
| 979 | expect(body.result.structuredContent.card.kind).toBe("feed"); | |
| 953 | 980 | }); |
| 954 | 981 | |
| 955 | 982 | test("a tool result carries the card it renders", async () => { |
new above
offline
Doesn't block:
feednever returnsnewtoday, so this branch is speculative and the card always says "Latest". Either havereadFeedreturn the count since the viewer's last look in a follow-up, or drop the branch so no unreachable code is left waiting for it.