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

Merged opened this

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

Merged

scout/forge:scout/3348-the-feed-tool-s-answer-draws-a-ui-forge → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/3348-the-feed-tool-s-answer-draws-a-ui-forge was merged into main as d59444c.

Review

Sign in to review this change.
5 files changed +80 −10
docs/MCP.md +2 −2
@@ -175,9 +175,9 @@ answers
175175
176176 ## Cards, the contract and the plugins (#1116)
177177
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
179179 `_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
181181 sandboxed page, which draws the `structuredContent.card` model that the
182182 tool result carries, with `textContent` only. The model is built in
183183 `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 {
35113511 }
35123512
35133513 /** 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);
35163516 return card === undefined ? {} : { structuredContent: { card } };
35173517 }
35183518
@@ -3612,7 +3612,7 @@ function rpcOne(
36123612 answer.ok
36133613 ? {
36143614 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),
36163616 }
36173617 : {
36183618 content: [
src/web/studio/cards.ts +27 −4
@@ -8,12 +8,15 @@
88 */
99
1010 import { html, raw } from "../html.ts";
11 import { timeAgo } from "../ui.ts";
1112
12 export type CardKind = "app" | "run" | "decision" | "wave";
13 export type CardKind = "app" | "run" | "decision" | "wave" | "feed";
1314
1415 export interface CardRow {
1516 readonly label: string;
1617 readonly value: string;
18 /** An absolute http(s) address the row's label links to. */
19 readonly link?: string;
1720 }
1821 export interface CardAction {
1922 readonly label: string;
@@ -33,7 +36,7 @@ export interface Card {
3336
3437 export const CARD_MIME = "text/html;profile=mcp-app";
3538 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"];
3740
3841 /** The tool whose answer each card draws. */
3942 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -41,6 +44,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4144 now: "run",
4245 asks: "decision",
4346 milestones: "wave",
47 feed: "feed",
4448 new_app: "app",
4549 };
4650
@@ -49,6 +53,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
4953 run: "Working now",
5054 decision: "Waiting on you",
5155 wave: "Wave",
56 feed: "Feed",
5257 };
5358
5459 type Obj = Record<string, unknown>;
@@ -58,9 +63,10 @@ const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
5863 const text = (v: unknown): string =>
5964 typeof v === "string" ? v : typeof v === "number" ? String(v) : "";
6065 const LIMIT = 12;
66 const FEED_LIMIT = 10;
6167
6268 /** 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 {
6470 const kind = CARD_OF_TOOL[tool];
6571 if (kind === undefined) return undefined;
6672 const rows: CardRow[] = [];
@@ -130,6 +136,19 @@ export function cardFor(tool: string, value: unknown): Card | undefined {
130136 if (repo !== "" && Number.isInteger(number) && number > 0)
131137 actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true });
132138 }
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";

Doesn't block: feed never returns new today, so this branch is speculative and the card always says "Latest". Either have readFeed return 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.

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 }
133152 } else {
134153 const limbs = list(obj(value)["limbs"]).map(obj);
135154 const repo = obj(obj(value)["repo"]);
@@ -156,7 +175,11 @@ let n = 0;
156175 function draw(card) {
157176 root.replaceChildren(el("h2", card.title), el("p", card.status, "status"));
158177 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 }
160183 root.append(dl);
161184 const field = card.actions.some((a) => a.words) ? document.createElement("input") : null;
162185 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 {
66 textContent = "";
77 className = "";
88 value = "";
9 onclick: (() => void) | undefined;
9 href = "";
10 onclick: ((ev?: any) => void) | undefined;
1011 focused = false;
1112 constructor(readonly tag: string) {}
1213 append(...n: Node[]) { this.children.push(...n); }
@@ -85,4 +86,23 @@ describe("the card shell", () => {
8586 const card = cardFor("asks", { rows: [{ line: "a", kind: "paused", items: ["x"], repo: "a/b", words: {} }] })!;
8687 expect(card.actions.some((a) => a.tool === "say")).toBe(false);
8788 });
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 });
88108 });
test/mcp.test.ts +27
@@ -933,6 +933,7 @@ describe("the forge over MCP", () => {
933933 expect(uris).toEqual([
934934 "ui://forge/app",
935935 "ui://forge/decision",
936 "ui://forge/feed",
936937 "ui://forge/run",
937938 "ui://forge/wave",
938939 ]);
@@ -950,6 +951,32 @@ describe("the forge over MCP", () => {
950951 expect(meta("now")).toBe("ui://forge/run");
951952 expect(meta("asks")).toBe("ui://forge/decision");
952953 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");
953980 });
954981
955982 test("a tool result carries the card it renders", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs