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

Merged opened this

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

Merged

scout/forge:scout/5319-mcp-issue-answers-with-a-ui-forge-issue → main 10 commits fork 1 thread · 1 unresolved

  • Merged. scout/5319-mcp-issue-answers-with-a-ui-forge-issue was merged into main as d14cc55.

Review

Sign in to review this change.
5 files changed +73 −7
docs/MCP.md +1 −1
@@ -82,7 +82,7 @@ for a tunnel and a forge that sleeps.
8282 | `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 |
8383 | `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 |
8484 | `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 | |
8686 | `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 |
8787 | `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 |
8888 | `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"
88
99 export const tool: Tool = {
1010 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",
1212 description:
1313 "One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted`.",
1414 inputSchema: {
@@ -53,7 +53,7 @@ export const tool: Tool = {
5353 const flag = (x: { untrusted?: string }) =>
5454 x.untrusted === undefined ? {} : { untrusted: true };
5555 const [self] = mark([issue]);
56 return good({
56 const answer = good({
5757 number: issue.number,
5858 title: issue.title,
5959 state: issue.state,
@@ -74,5 +74,7 @@ export const tool: Tool = {
7474 blockedBy: deps.blockedBy.filter(seen).map(brief),
7575 blocks: deps.blocks.filter(seen).map(brief),
7676 });
77 // Where the issue lives, for the card's links.
78 return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}` } };
7779 },
7880 };
src/web/studio/cards.ts +23 −2
@@ -10,7 +10,7 @@
1010 import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212
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";
1414
1515 export interface CardRow {
1616 readonly label: string;
@@ -39,7 +39,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
3939
4040 export const CARD_MIME = "text/html;profile=mcp-app";
4141 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"];
4343
4444 /** The tool whose answer each card draws. */
4545 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -53,6 +53,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5353 explore: "explore",
5454 new_app: "app",
5555 pull: "pull",
56 issue: "issue",
5657 };
5758
5859 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -64,6 +65,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
6465 feed: "Feed",
6566 explore: "Explore",
6667 pull: "Pull",
68 issue: "Issue",
6769 };
6870
6971 type Obj = Record<string, unknown>;
@@ -148,6 +150,25 @@ export function cardFor(
148150 if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview });
149151 return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions };
150152 }
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))

Nit: blockedBy includes closed blockers, so this row can say "Waits for #3 · closed". Consider listing only the open ones.

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 }
151172 if (kind === "app") {
152173 const apps = list(value).map(obj);
153174 status = `${apps.length} app${apps.length === 1 ? "" : "s"}`;
test/mcp.test.ts +42
@@ -613,6 +613,15 @@ describe("the forge over MCP", () => {
613613 });
614614 if (!opened.ok) throw new Error(String(opened.error));
615615 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);
616625
617626 // Sealed: the writer reads it; a reader of a public repo is told not found.
618627 const pub = await createRepo(app, alice, { name: "sealedpub", visibility: "public" });
@@ -1187,6 +1196,7 @@ describe("the forge over MCP", () => {
11871196 "ui://forge/decision",
11881197 "ui://forge/explore",
11891198 "ui://forge/feed",
1199 "ui://forge/issue",
11901200 "ui://forge/map",
11911201 "ui://forge/pull",
11921202 "ui://forge/run",
@@ -1208,6 +1218,7 @@ describe("the forge over MCP", () => {
12081218 expect(meta("milestones")).toBe("ui://forge/wave");
12091219 expect(meta("map")).toBe("ui://forge/map");
12101220 expect(meta("feed")).toBe("ui://forge/feed");
1221 expect(meta("issue")).toBe("ui://forge/issue");
12111222 expect(meta("explore")).toBe("ui://forge/explore");
12121223 expect(meta("pull")).toBe("ui://forge/pull");
12131224 });
@@ -1283,6 +1294,37 @@ describe("the forge over MCP", () => {
12831294 expect(hostile.rows.some((r) => r.label === "Preview")).toBe(false);
12841295 });
12851296
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
12861328 test("the decision card links each ask to its pull or issue, and to nothing unsafe", async () => {
12871329 const { cardFor } = await import("../src/web/studio/cards.ts");
12881330 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", () => {
201201 });
202202
203203 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.
205205 const feed = JSON.parse(await mcp("feed", {})).result;
206206 const card = feed.structuredContent?.card;
207207 expect(card).toBeDefined();
208208 expect(card.rows.map((r: { label: string }) => r.label).join("\n")).toContain(EVIL);
209209 expect(JSON.stringify(card)).not.toContain("&lt;");
210210 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("&lt;");
212213 expect(JSON.parse(issue.content[0].text).title).toBe(EVIL);
213214 const shell = CARD_SHELL("feed");
214215 expect(shell).not.toContain("innerHTML");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs