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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11319-mcp-releases-also-returns-a-ui-forge-rel → main 3 commits fork 4 threads · 4 unresolved
3 files
changed +31
−6
docs/MCP.md +2 −2
| @@ -506,7 +506,7 @@ answers | ||
| 506 | 506 | | `ui://forge/milestones` | `milestones` | The milestones and how far each is. | |
| 507 | 507 | | `ui://forge/search` | `search` | The query on top, one row per hit (repository, issue, pull, code) with its kind, title and link. | |
| 508 | 508 | | `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. | | |
| 510 | 510 | |
| 511 | 511 | 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 |
| 512 | 512 | its ready, working and waiting counts, and the first five ready issues linked |
| @@ -521,7 +521,7 @@ answers | ||
| 521 | 521 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a |
| 522 | 522 | linked production row when the app has an http(s) URL; the `new_app` card links its |
| 523 | 523 | 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. | |
| 525 | 525 | 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`. |
| 526 | 526 | `resources/list` and `resources/read` serve one small |
| 527 | 527 | 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; | ||
| 118 | 118 | export const ISSUES_ROWS = 20; |
| 119 | 119 | const ASKED_WORDS = 80; |
| 120 | 120 | const ASKED_LIMIT = 20; |
| 121 | const RELEASE_ROWS = 20; | |
| 121 | const RELEASE_ROWS = 10; | |
| 122 | 122 | /** A waiting row's words are cut to this many characters. */ |
| 123 | 123 | const SAYS_WORDS = 160; |
| 124 | 124 | /** A board card shows this many panels, then counts the rest. */ |
| @@ -133,6 +133,8 @@ const FOLLOW_WORDS: Readonly<Record<string, string>> = { | ||
| 133 | 133 | "dont-follow": "off", |
| 134 | 134 | }; |
| 135 | 135 | /** 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._-]+$/; | |
| 136 | 138 | const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/; |
| 137 | 139 | |
| 138 | 140 | /** A run state as one glyph beside its word: a card is read at a glance. */ |
| @@ -478,15 +480,21 @@ export function cardFor( | ||
| 478 | 480 | // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card. |
| 479 | 481 | const items = list(obj(value)["items"]).map(obj); |
| 480 | 482 | 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) { | |
| 484 | 487 | const p = obj(i["pull"]); |
| 485 | 488 | const pull = text(p["number"]) === "" ? "" : `#${text(p["number"])} · `; |
| 486 | 489 | const at = text(i["landed"]); |
| 490 | const repo = text(p["repo"]); | |
outdated
slugOf allows |
||
| 491 | const path = `/${repo}/pulls/${text(p["number"])}`; | |
| 487 | 492 | rows.push({ |
| 488 | 493 | label: text(i["version"]) || text(i["sha"]), |
| 489 | 494 | 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 | : {}), | |
| 490 | 498 | }); |
| 491 | 499 | } |
| 492 | 500 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
test/mcp-releases-card.test.ts +17
| @@ -92,6 +92,23 @@ describe("MCP releases card", () => { | ||
| 92 | 92 | expect(res.structuredContent.card.rows[3].value).not.toContain("1 pull"); |
| 93 | 93 | }); |
| 94 | 94 | |
| 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 ( |
||
| 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 | ||
| 95 | 112 | test("proof words, one per branch", () => { |
| 96 | 113 | expect(proofOf("rolled_back", true)).toBe("rolled back"); |
| 97 | 114 | expect(proofOf("rolled_back", false)).toBe("rolled back"); |
The status line above still counts all kept releases while only 10 rows show; word it as
last 10 of Nso the card does not contradict itself.