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

MCP `releases` also returns a `ui://forge/releases` card without a new tool: what went live, newest first, each row linked to its pull #11329

Merged opened this

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

Merged

mason/forge:mason/11319-mcp-releases-also-returns-a-ui-forge-rel → main 3 commits fork 4 threads · 4 unresolved

  • Merged. mason/11319-mcp-releases-also-returns-a-ui-forge-rel was merged into main as fd54236.

Review

Sign in to review this change.
3 files changed +31 −6
docs/MCP.md +2 −2
@@ -506,7 +506,7 @@ answers
506506 | `ui://forge/milestones` | `milestones` | The milestones and how far each is. |
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. |
509 | `ui://forge/release` | `releases` | Kept releases, newest first, each proven, unproven or rolled back. |
509 | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. |
510510
511511 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
512512 its ready, working and waiting counts, and the first five ready issues linked
@@ -521,7 +521,7 @@ answers
521521 The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a
522522 linked production row when the app has an http(s) URL; the `new_app` card links its
523523 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, newest first: its version (else its short sha), how long ago it landed, the pull that landed it (as the releases page names it) 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.
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.
525525 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`.
526526 `resources/list` and `resources/read` serve one small
527527 sandboxed page, which draws the `structuredContent.card` model that the
src/web/studio/cards.ts +12 −4
@@ -118,7 +118,7 @@ export const SEARCH_ROWS = 20;
118118 export const ISSUES_ROWS = 20;
119119 const ASKED_WORDS = 80;
120120 const ASKED_LIMIT = 20;
121 const RELEASE_ROWS = 20;
121 const RELEASE_ROWS = 10;

The status line above still counts all kept releases while only 10 rows show; word it as last 10 of N so the card does not contradict itself.

122122 /** A waiting row's words are cut to this many characters. */
123123 const SAYS_WORDS = 160;
124124 /** A board card shows this many panels, then counts the rest. */
@@ -133,6 +133,8 @@ const FOLLOW_WORDS: Readonly<Record<string, string>> = {
133133 "dont-follow": "off",
134134 };
135135 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
136 /** A real `owner/name`: no empty, `.` or `..` segment, nothing that climbs out of the repository. */

Non-blocking: this doc comment describes RUN_ID but now sits above REPO_SLUG. Move the REPO_SLUG block above it.

137 const REPO_SLUG = /^(?!\.{1,2}\/)(?!.*\/\.{1,2}$)[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
136138 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
137139
138140 /** A run state as one glyph beside its word: a card is read at a glance. */
@@ -478,15 +480,21 @@ export function cardFor(
478480 // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card.
479481 const items = list(obj(value)["items"]).map(obj);
480482 if (!Array.isArray(obj(value)["items"])) return undefined;
481 const count = (w: string): number => items.filter((i) => i["proof"] === w).length;
482 status = `${items.length} kept · ${count("proven")} proven · ${count("unproven")} unproven · ${count("rolled back")} rolled back`;
483 for (const i of items.slice(0, RELEASE_ROWS)) {
483 const shown = items.slice(0, RELEASE_ROWS);
484 const count = (w: string): number => shown.filter((i) => i["proof"] === w).length;
485 status = `${items.length > shown.length ? `last ${shown.length} of ${items.length}` : `${items.length} kept`} · ${count("proven")} proven · ${count("unproven")} unproven · ${count("rolled back")} rolled back`;
486 for (const i of shown) {
484487 const p = obj(i["pull"]);
485488 const pull = text(p["number"]) === "" ? "" : `#${text(p["number"])} · `;
486489 const at = text(i["landed"]);
490 const repo = text(p["repo"]);
outdated

slugOf allows a/.., giving /a/../pulls/N. Require a real slug and check here(origin, path) as the runs card does. Build path once and reuse it.

491 const path = `/${repo}/pulls/${text(p["number"])}`;
487492 rows.push({
488493 label: text(i["version"]) || text(i["sha"]),
489494 value: `${at === "" ? "" : `${timeAgo(at)} · `}${pull}${text(i["proof"])}`,
495 ...(web(origin) && REPO_SLUG.test(repo) && /^[1-9]\d*$/.test(text(p["number"])) && here(origin, path)
496 ? { link: origin + path }
497 : {}),
490498 });
491499 }
492500 return { kind, title: CARD_TITLE[kind], status, rows, actions };
test/mcp-releases-card.test.ts +17
@@ -92,6 +92,23 @@ describe("MCP releases card", () => {
9292 expect(res.structuredContent.card.rows[3].value).not.toContain("1 pull");
9393 });
9494
95 test("the card is the last 10, each row linked to its pull, and escapes the link's fields", () => {
96 const items = Array.from({ length: 12 }, (_, n) => ({ sha: `s${n}`, version: null, pull: { repo: "o/r", number: n + 1 }, landed: "", proof: "proven" }));
97 const card = cardFor("releases", { items }, "https://f.test")!;
98 expect(card.rows.length).toBe(10);
99 expect(card.rows[0]!.link).toBe("https://f.test/o/r/pulls/1");
100 for (const repo of ["//evil", "a/..", "./x", "../x", "a/.", "a/b/c", "a b/c"]) {
101 const bad = cardFor("releases", { items: [{ sha: "a", pull: { repo, number: 1 }, landed: "", proof: "proven" }] }, "https://f.test")!;
102 expect(bad.rows[0]!.link).toBeUndefined();
103 }
outdated

Add traversal cases (a/.., ./x) and a hostile version or proof to show every field is escaped. //evil alone is already rejected by slugOf.

104 expect(cardFor("releases", { items }, "https://f.test")!.status).toStartWith("last 10 of 12 · 10 proven");
105 const evil = `<img src=x onerror=alert(1)>`;
106 const hostile = cardFor("releases", { items: [{ sha: evil, version: evil, pull: { repo: "o/r", number: 1 }, landed: "", proof: evil }] }, "https://f.test")!;
107 expect(hostile.rows[0]!.label).toBe(evil);
108 expect(hostile.rows[0]!.value).toContain(evil);
109 expect(CARD_SHELL("release")).not.toContain(evil);
110 });
111
95112 test("proof words, one per branch", () => {
96113 expect(proofOf("rolled_back", true)).toBe("rolled back");
97114 expect(proofOf("rolled_back", false)).toBe("rolled back");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs