Public: anyone can read this; the crew can change it.
MCP `issue` answers with a `ui://forge/issue` card: title, state, assignee, what it waits for #6673
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5319-mcp-issue-answers-with-a-ui-forge-issue → main 10 commits fork 1 thread · 1 unresolved
5 files
changed +73
−7
docs/MCP.md +1 −1
| @@ -82,7 +82,7 @@ for a tunnel and a forge that sleeps. | ||
| 82 | 82 | | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes | |
| 83 | 83 | | `hooks` | `{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown | yes | |
| 84 | 84 | | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes | |
| 85 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found | | | |
| 85 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | | | |
| 86 | 86 | | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes | |
| 87 | 87 | | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes | |
| 88 | 88 | | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes | |
src/http/mcp/tools/issue.ts +4 −2
| @@ -8,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 8 | 8 | |
| 9 | 9 | export const tool: Tool = { |
| 10 | 10 | name: "issue", |
| 11 | doc: "`{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found", | |
| 11 | doc: "`{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments", | |
| 12 | 12 | description: |
| 13 | 13 | "One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted`.", |
| 14 | 14 | inputSchema: { |
| @@ -53,7 +53,7 @@ export const tool: Tool = { | ||
| 53 | 53 | const flag = (x: { untrusted?: string }) => |
| 54 | 54 | x.untrusted === undefined ? {} : { untrusted: true }; |
| 55 | 55 | const [self] = mark([issue]); |
| 56 | return good({ | |
| 56 | const answer = good({ | |
| 57 | 57 | number: issue.number, |
| 58 | 58 | title: issue.title, |
| 59 | 59 | state: issue.state, |
| @@ -74,5 +74,7 @@ export const tool: Tool = { | ||
| 74 | 74 | blockedBy: deps.blockedBy.filter(seen).map(brief), |
| 75 | 75 | blocks: deps.blocks.filter(seen).map(brief), |
| 76 | 76 | }); |
| 77 | // Where the issue lives, for the card's links. | |
| 78 | return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}` } }; | |
| 77 | 79 | }, |
| 78 | 80 | }; |
src/web/studio/cards.ts +23 −2
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "explore"; | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "explore" | "issue"; | |
| 14 | 14 | |
| 15 | 15 | export interface CardRow { |
| 16 | 16 | readonly label: string; |
| @@ -39,7 +39,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 39 | 39 | |
| 40 | 40 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 41 | 41 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 42 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "explore"]; | |
| 42 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "explore", "issue"]; | |
| 43 | 43 | |
| 44 | 44 | /** The tool whose answer each card draws. */ |
| 45 | 45 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -53,6 +53,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 53 | 53 | explore: "explore", |
| 54 | 54 | new_app: "app", |
| 55 | 55 | pull: "pull", |
| 56 | issue: "issue", | |
| 56 | 57 | }; |
| 57 | 58 | |
| 58 | 59 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -64,6 +65,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 64 | 65 | feed: "Feed", |
| 65 | 66 | explore: "Explore", |
| 66 | 67 | pull: "Pull", |
| 68 | issue: "Issue", | |
| 67 | 69 | }; |
| 68 | 70 | |
| 69 | 71 | type Obj = Record<string, unknown>; |
| @@ -148,6 +150,25 @@ export function cardFor( | ||
| 148 | 150 | if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview }); |
| 149 | 151 | return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions }; |
| 150 | 152 | } |
| 153 | if (kind === "issue") { | |
| 154 | const v = obj(value); | |
| 155 | const number = text(v["number"]); | |
| 156 | // A number that names a pull answers `{kind:"pull"}`: no issue, no card. | |
| 157 | if (v["kind"] === "pull" || !/^[1-9]\d*$/.test(number)) return undefined; | |
| 158 | const slug = text(extra["repo"]); | |
| 159 | const base = web(origin) && /^[^/\s]+\/[^/\s]+$/.test(slug) ? `${origin}/${slug}/issues/` : ""; | |
| 160 | const at = (n: string): { link?: string } => (base !== "" && /^\d+$/.test(n) ? { link: base + n } : {}); | |
| 161 | status = text(v["state"]) + (v["closeReason"] ? ` · ${text(v["closeReason"])}` : ""); | |
| 162 | rows.push({ label: `#${number}`, value: text(v["title"]), ...at(number) }); | |
| 163 | // The title is an outsider's when the answer says so; the card says so too. | |
| 164 | if (v["untrusted"] === true) rows.push({ label: "Author", value: "untrusted: not a writer on this repository" }); | |
| 165 | rows.push({ label: "Assignee", value: text(v["assignee"]) || "nobody" }); | |
| 166 | const labels = list(v["labels"]).map(text).filter((l) => l !== ""); | |
| 167 | if (labels.length > 0) rows.push({ label: "Labels", value: labels.join(", ") }); | |
| 168 | for (const b of list(v["blockedBy"]).map(obj).slice(0, LIMIT)) | |
| 169 | rows.push({ label: "Waits for", value: `#${text(b["number"])} · ${text(b["state"])}`, ...at(text(b["number"])) }); | |
| 170 | return { kind, title: `Issue #${number}`, status, rows, actions }; | |
| 171 | } | |
| 151 | 172 | if (kind === "app") { |
| 152 | 173 | const apps = list(value).map(obj); |
| 153 | 174 | status = `${apps.length} app${apps.length === 1 ? "" : "s"}`; |
test/mcp.test.ts +42
| @@ -613,6 +613,15 @@ describe("the forge over MCP", () => { | ||
| 613 | 613 | }); |
| 614 | 614 | if (!opened.ok) throw new Error(String(opened.error)); |
| 615 | 615 | expect((await tool(rw, "issue", { repo: "alice/game", number: opened.value.number })).value).toEqual({ kind: "pull", tool: "pull" }); |
| 616 | // Through MCP the card is built from the answer's repo; a pull's number has none. | |
| 617 | const viaPull = await call(rw, "tools/call", { name: "issue", arguments: { repo: "alice/game", number: opened.value.number } }); | |
| 618 | expect(viaPull.body.result.structuredContent).toBeUndefined(); | |
| 619 | const viaIssue = await call(rw, "tools/call", { name: "issue", arguments: { repo: "alice/game", number: waiting.number } }); | |
| 620 | const card = viaIssue.body.result.structuredContent.card; | |
| 621 | expect(card.kind).toBe("issue"); | |
| 622 | expect(card.rows[0].link).toMatch(/^https?:\/\/.+\/alice\/game\/issues\/\d+$/); | |
| 623 | expect(card.rows[0].link.endsWith(`/alice/game/issues/${waiting.number}`)).toBe(true); | |
| 624 | expect(card.rows.find((r: any) => r.label === "Waits for").link.endsWith(`/alice/game/issues/${first.number}`)).toBe(true); | |
| 616 | 625 | |
| 617 | 626 | // Sealed: the writer reads it; a reader of a public repo is told not found. |
| 618 | 627 | const pub = await createRepo(app, alice, { name: "sealedpub", visibility: "public" }); |
| @@ -1187,6 +1196,7 @@ describe("the forge over MCP", () => { | ||
| 1187 | 1196 | "ui://forge/decision", |
| 1188 | 1197 | "ui://forge/explore", |
| 1189 | 1198 | "ui://forge/feed", |
| 1199 | "ui://forge/issue", | |
| 1190 | 1200 | "ui://forge/map", |
| 1191 | 1201 | "ui://forge/pull", |
| 1192 | 1202 | "ui://forge/run", |
| @@ -1208,6 +1218,7 @@ describe("the forge over MCP", () => { | ||
| 1208 | 1218 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| 1209 | 1219 | expect(meta("map")).toBe("ui://forge/map"); |
| 1210 | 1220 | expect(meta("feed")).toBe("ui://forge/feed"); |
| 1221 | expect(meta("issue")).toBe("ui://forge/issue"); | |
| 1211 | 1222 | expect(meta("explore")).toBe("ui://forge/explore"); |
| 1212 | 1223 | expect(meta("pull")).toBe("ui://forge/pull"); |
| 1213 | 1224 | }); |
| @@ -1283,6 +1294,37 @@ describe("the forge over MCP", () => { | ||
| 1283 | 1294 | expect(hostile.rows.some((r) => r.label === "Preview")).toBe(false); |
| 1284 | 1295 | }); |
| 1285 | 1296 | |
| 1297 | test("the issue card carries state, assignee, labels and what it waits for, with absolute links and no comments", async () => { | |
| 1298 | const { cardFor } = await import("../src/web/studio/cards.ts"); | |
| 1299 | const answer = { | |
| 1300 | number: 7, | |
| 1301 | title: "<b>Fix</b>", | |
| 1302 | state: "open", | |
| 1303 | closeReason: null, | |
| 1304 | body: "secret body", | |
| 1305 | assignee: "scout", | |
| 1306 | labels: ["bug"], | |
| 1307 | untrusted: true, | |
| 1308 | comments: [{ author: "x", body: "COMMENTTEXT" }], | |
| 1309 | blockedBy: [{ number: 3, title: "T3", state: "open" }], | |
| 1310 | blocks: [], | |
| 1311 | }; | |
| 1312 | const card = cardFor("issue", answer, "https://forge.test", { repo: "alice/game" })!; | |
| 1313 | expect(card.kind).toBe("issue"); | |
| 1314 | expect(card.status).toBe("open"); | |
| 1315 | expect(card.rows[0]).toEqual({ label: "#7", value: "<b>Fix</b>", link: "https://forge.test/alice/game/issues/7" }); | |
| 1316 | expect(card.rows.find((r) => r.label === "Assignee")!.value).toBe("scout"); | |
| 1317 | expect(card.rows.find((r) => r.label === "Labels")!.value).toBe("bug"); | |
| 1318 | expect(card.rows.find((r) => r.label === "Waits for")!.link).toBe("https://forge.test/alice/game/issues/3"); | |
| 1319 | expect(card.rows.some((r) => r.label === "Author" && r.value.startsWith("untrusted"))).toBe(true); | |
| 1320 | expect(JSON.stringify(card)).not.toContain("COMMENTTEXT"); | |
| 1321 | expect(JSON.stringify(card)).not.toContain("secret body"); | |
| 1322 | expect(cardFor("issue", answer, "https://forge.test")!.rows[0]!.link).toBeUndefined(); | |
| 1323 | // A number that names a pull is not an issue: no card. | |
| 1324 | expect(cardFor("issue", { kind: "pull", tool: "pull" }, "https://forge.test", { repo: "alice/game" })).toBeUndefined(); | |
| 1325 | expect(cardFor("issue", {}, "https://forge.test", { repo: "alice/game" })).toBeUndefined(); | |
| 1326 | }); | |
| 1327 | ||
| 1286 | 1328 | test("the decision card links each ask to its pull or issue, and to nothing unsafe", async () => { |
| 1287 | 1329 | const { cardFor } = await import("../src/web/studio/cards.ts"); |
| 1288 | 1330 | const words = { decision: "Ship it?", options: ["ship", "close"] }; |
test/xss-everywhere.test.ts +3 −2
| @@ -201,14 +201,15 @@ describe("one hostile string, every surface", () => { | ||
| 201 | 201 | }); |
| 202 | 202 | |
| 203 | 203 | test("MCP feed and issue cards carry it as data the shell draws with textContent", async () => { |
| 204 | // `feed` has a card; `issue` answers with data alone, so it has none. | |
| 204 | // `feed` and `issue` both have cards; the hostile text rides as raw data. | |
| 205 | 205 | const feed = JSON.parse(await mcp("feed", {})).result; |
| 206 | 206 | const card = feed.structuredContent?.card; |
| 207 | 207 | expect(card).toBeDefined(); |
| 208 | 208 | expect(card.rows.map((r: { label: string }) => r.label).join("\n")).toContain(EVIL); |
| 209 | 209 | expect(JSON.stringify(card)).not.toContain("<"); |
| 210 | 210 | const issue = JSON.parse(await mcp("issue", { repo: "alice/game", number: issueNo })).result; |
| 211 | expect(issue.structuredContent?.card).toBeUndefined(); | |
| 211 | expect(issue.structuredContent.card.rows[0].value).toBe(EVIL); | |
| 212 | expect(JSON.stringify(issue.structuredContent.card)).not.toContain("<"); | |
| 212 | 213 | expect(JSON.parse(issue.content[0].text).title).toBe(EVIL); |
| 213 | 214 | const shell = CARD_SHELL("feed"); |
| 214 | 215 | expect(shell).not.toContain("innerHTML"); |
new above
offline
Nit:
blockedByincludes closed blockers, so this row can say "Waits for #3 · closed". Consider listing only the open ones.