Public: anyone can read this; the crew can change it.

MCP `releases` draws a `ui://forge/releases` card: what went live, newest first, each row linked to its pull #11330

Merged opened this

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

Merged

scout/forge:scout/11225-mcp-releases-draws-a-ui-forge-releases-c → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/11225-mcp-releases-draws-a-ui-forge-releases-c was merged into main as 4ad2e0b.

Review

Sign in to review this change.
5 files changed +65 −8
docs/MCP.md +2 −1
@@ -507,6 +507,7 @@ answers
507507 | `ui://forge/search` | `search` | The query on top, one row per hit (repository, issue, pull, code) with its kind, title and link. |
508508 | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. |
509509 | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. |
510 | `ui://forge/releases` | `releases` with `shipped` | What shipped: one linked row per landing, the live version, what is on its way. |
510511
511512 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
512513 its ready, working and waiting counts, and the first five ready issues linked
@@ -521,7 +522,7 @@ answers
521522 The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a
522523 linked production row when the app has an http(s) URL; the `new_app` card links its
523524 Production row the same way. Any other address stays plain text, never a link.
524 The Releases card (`releases`, `ui://forge/release`) is one row per kept release, the last 10, newest first (the status line reads `last 10 of N` when there are more), its version (else its short sha), how long ago it landed, the pull that landed it (as the releases page names it, linked to that pull) and one word, `proven` (the keeper verified it), `unproven` (preflight failed, the keeper never cut it or is still verifying it) or `rolled back` (the keeper rolled it back). The keeper's row is looked up by sha, however old. The rows are the `items` of the tool's text, which carry the same `proof` and `pull`; `releases` with `shipped` has no card.
525 The Releases card (`releases`, `ui://forge/release`) is one row per kept release, the last 10, newest first (the status line reads `last 10 of N` when there are more), its version (else its short sha), how long ago it landed, the pull that landed it (as the releases page names it, linked to that pull) and one word, `proven` (the keeper verified it), `unproven` (preflight failed, the keeper never cut it or is still verifying it) or `rolled back` (the keeper rolled it back). The keeper's row is looked up by sha, however old. The rows are the `items` of the tool's text, which carry the same `proof` and `pull`; `releases` with `shipped` draws the Shipped card (`ui://forge/releases`).
525526 The Forks card (`fork_tree`, `ui://forge/forks`) is one row per repository you may read, nested by a `depth` field the card pads (not leading spaces, which HTML collapses); the value says how it follows: `follows on its own`, `takes updates`, `offers updates` (the card's own wording for the `offers` mode) or `off`.
526527 `resources/list` and `resources/read` serve one small
527528 sandboxed page, which draws the `structuredContent.card` model that the
src/http/mcp/tools/releases.ts +1 −1
@@ -3,7 +3,7 @@ import { bad, good, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "releases",
6 doc: "`{shipped: true, repo?}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows, for `repo` (`all` or `owner/name`; absent is the forge's own, as on the page). `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page",
6 doc: "`{shipped: true, repo?}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows (the `ui://forge/releases` card), for `repo` (`all` or `owner/name`; absent is the forge's own, as on the page). `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page",
77 description: "Kept releases, newest first.",
88 inputSchema: { type: "object", properties: { shipped: { type: "boolean" }, repo: { type: "string" } } },
99 writes: false,
src/web/studio/cards.ts +27 −3
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "search" | "issues";
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";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4444
4545 export const CARD_MIME = "text/html;profile=mcp-app";
4646 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", "search", "issues"];
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"];
4848
4949 /** The tool whose answer each card draws. */
5050 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -92,6 +92,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
9292 forks: "Forks",
9393 milestones: "Milestones",
9494 release: "Releases",
95 releases: "Shipped",
9596 search: "Search",
9697 issues: "Issues",
9798 };
@@ -477,7 +478,30 @@ export function cardFor(
477478 return { kind, title: CARD_TITLE[kind], status, rows, actions };
478479 }
479480 if (kind === "release") {
480 // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card.
481 // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; with `shipped` it answers
482 // `{ version, commit, live: [{ repo, number, title, at }], onItsWay }`, the /changelog list.
483 if (Array.isArray(obj(value)["live"])) {
484 const kind = "releases";
outdated

The issue names the card ui://forge/releases; this reuses ui://forge/release. Register the named resource or get the issue changed; the tool doc line also now points at ui://forge/release.

485 const v = obj(value);
486 const live = list(v["live"]).map(obj);
487 const onItsWay = list(v["onItsWay"]).map(obj);
488 const commit = text(v["commit"]);
489 status = `live ${text(v["version"])}${commit === "" ? "" : ` · ${commit.slice(0, 7)}`} · ${live.length} shipped · ${onItsWay.length} on its way`;
490 const row = (l: Record<string, unknown>, way: boolean): CardRow => {
491 const repo = text(l["repo"]);
492 const number = text(l["number"]);
493 const path = `/${repo}/pulls/${number}`;
494 const at = text(l["at"]);
495 return {
496 label: `#${number} ${text(l["title"])}`,
497 value: way ? "on its way" : `${repo}${at === "" ? "" : ` · ${timeAgo(at)}`}`,
498 ...(web(origin) && slugOf(repo) && /^[1-9]\d*$/.test(number) && here(origin, path) ? { link: origin + path } : {}),
499 };
500 };
501 for (const l of live.slice(0, RELEASE_ROWS)) rows.push(row(l, false));
502 for (const l of onItsWay.slice(0, Math.max(0, RELEASE_ROWS - rows.length))) rows.push(row(l, true));
503 return { kind, title: CARD_TITLE[kind], status, rows, actions };
504 }
481505 const items = list(obj(value)["items"]).map(obj);
482506 if (!Array.isArray(obj(value)["items"])) return undefined;
483507 const shown = items.slice(0, RELEASE_ROWS);
test/mcp-releases-card.test.ts +34 −3
@@ -133,9 +133,40 @@ describe("MCP releases card", () => {
133133 expect(SHELL_JS).not.toContain("innerHTML");
134134 });
135135
136 test("releases with shipped has no card", async () => {
136 test("releases with shipped draws the listed releases card: rows match the JSON, each linked", async () => {
137 const uris = ((await rpc("resources/list")).resources as any[]).map((r) => r.uri);
138 expect(uris).toContain("ui://forge/releases");
139 expect((await rpc("resources/read", { uri: "ui://forge/releases" })).contents[0].uri).toBe("ui://forge/releases");
137140 const res = await rpc("tools/call", { name: "releases", arguments: { shipped: true } });
138 expect(res.structuredContent?.card).toBeUndefined();
139 expect(cardFor("releases", { version: "1", live: [], onItsWay: [] })).toBeUndefined();
141 const json = JSON.parse(res.content[0].text);
142 const card = res.structuredContent.card;
143 expect(card.kind).toBe("releases");
144 expect(uris).toContain(`ui://forge/${card.kind}`);
145 expect(card.status).toContain(`live ${json.version}`);
146 const all = [...json.live, ...json.onItsWay];
147 expect(card.rows.length).toBe(Math.min(20, all.length));
148 json.live.slice(0, card.rows.length).forEach((l: any, i: number) => {
149 expect(card.rows[i].label).toBe(`#${l.number} ${l.title}`);
150 expect(card.rows[i].link).toBe(`${servers.url}/${l.repo}/pulls/${l.number}`);
151 });
152 });
153
154 test("a changelog longer than the cap is cut to the cap, live rows first", () => {
155 const live = Array.from({ length: 25 }, (_, i) => ({ repo: "a/b", number: i + 1, title: `t${i}`, at: "" }));
156 const card = cardFor("releases", { version: "1", commit: null, live, onItsWay: [{ repo: "a/b", number: 99, title: "n", at: "" }] }, "https://f.example")!;
157 expect(card.kind).toBe("releases");
158 expect(card.rows.length).toBe(10);
159 expect(card.rows.every((r) => r.value !== "on its way")).toBe(true);
160 });
161
162 test("the shipped card links each row to its pull and keeps a hostile title as text", () => {
163 const evil = `<img src=x onerror=alert(1)>`;
164 const card = cardFor("releases", { version: "1.0", commit: "abcdef1234", live: [{ repo: "a/b", number: 3, title: evil, at: "" }], onItsWay: [{ repo: "a/b", number: 4, title: "next", at: "" }] }, "https://f.example")!;
165 expect(card.status).toBe("live 1.0 · abcdef1 · 1 shipped · 1 on its way");
166 expect(card.rows[0]!.label).toBe(`#3 ${evil}`);
167 expect(card.rows[0]!.link).toBe("https://f.example/a/b/pulls/3");
168 expect(card.rows[1]!.value).toBe("on its way");
169 expect(card.kind).toBe("releases");
170 expect(CARD_SHELL("release")).not.toContain(evil);
140171 });
141172 });
test/mcp.test.ts +1
@@ -1302,6 +1302,7 @@ describe("the forge over MCP", () => {
13021302 "ui://forge/pull",
13031303 "ui://forge/pulls",
13041304 "ui://forge/release",
1305 "ui://forge/releases",
13051306 "ui://forge/run",
13061307 "ui://forge/runs",
13071308 "ui://forge/scorecard",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs