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

MCP `pull` answers with a `ui://forge/pull` card: title, state, checks, preview link #5345

Merged opened this

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

Merged

scout/forge:scout/5318-mcp-pull-answers-with-a-ui-forge-pull-ca → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/5318-mcp-pull-answers-with-a-ui-forge-pull-ca was merged into main as 3803951.

Review

Sign in to review this change.
4 files changed +151 −16
docs/MCP.md +3 −3
@@ -35,7 +35,7 @@ for a tunnel and a forge that sleeps.
3535 | `thought` | `{ id }`: where one of your thoughts went (its id comes from `tell` without a repo): the placement, and the earlier thoughts it repeats or takes back and the later ones that did the same; not yours and not there answer alike, `not_found` | |
3636 | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes |
3737 | `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 | |
38 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks` | |
38 | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | |
3939 | `edit` | a new title or body for an issue or a pull: its author's, or a writer's; an agent edits only what it wrote | yes |
4040 | `close` | `{ repo, number, state?, reason?, into? }`: close an issue or a pull (`state` closed by default) or reopen it (`open`); the same rules and refusals as `PATCH …/issues/:number`: an agent closes only what the rules give it and reopens nothing; answers `state` and `closeReason` | yes |
4141 | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | no |
@@ -223,9 +223,9 @@ answers
223223
224224 ## Cards, the contract and the plugins (#1116)
225225
226 - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `map` and `feed` name a `ui://` resource in
226 - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `map`, `feed` and `pull` name a `ui://` resource in
227227 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
228 `decision`, `wave`, `map`, `feed`. The Plan card (`map`) shows one row per pillar with
228 `decision`, `wave`, `map`, `feed`, `pull`. The Plan card (`map`) shows one row per pillar with
229229 its ready, working and waiting counts, and the first five ready issues linked
230230 to their pages; it has no actions. `resources/list` and `resources/read` serve one small
231231 sandboxed page, which draws the `structuredContent.card` model that the
src/http/mcp.ts +32 −10
@@ -161,6 +161,7 @@ import { blobForRender, listTree } from "../git/read.ts";
161161 import { CREATE_ERRORS, DELETE_ERRORS } from "../web/pages/branches.ts";
162162 import { askToLiveFor } from "../web/ask_to_live.ts";
163163 import { getAppByRepo } from "../core/apps.ts";
164 import { previewEnv } from "../apps/data.ts";
164165 import {
165166 ARCHIVED_WHY,
166167 canAdmin,
@@ -192,6 +193,7 @@ import {
192193 } from "../crew/direct.ts";
193194 import { createLabel, deleteLabel, setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts";
194195 import {
196 appView,
195197 deleteAppEnvVar,
196198 listAppEnv,
197199 newApp,
@@ -271,7 +273,8 @@ const server = () => ({ name: "forge", version: running().version });
271273
272274 type Args = Record<string, unknown>;
273275 type Answer =
274 { ok: true; value: unknown } | { ok: false; error: string; message: string };
276 | { ok: true; value: unknown; card?: Record<string, unknown> }
277 | { ok: false; error: string; message: string };
275278
276279 const good = (value: unknown): Answer => ({ ok: true, value });
277280 const attachWords = (error: string): string =>
@@ -895,7 +898,7 @@ const TOOLS: readonly Tool[] = [
895898 pull.state === "open" &&
896899 pull.mergedAt === null &&
897900 checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`));
898 return good({
901 const answer = good({
899902 number: pull.number,
900903 title: pull.title,
901904 state: pull.mergedAt !== null ? "merged" : pull.state,
@@ -906,6 +909,20 @@ const TOOLS: readonly Tool[] = [
906909 checkStale: stale,
907910 ...(state ?? {}),
908911 });
912 // What the card needs and the text does not say: where the pull lives
913 // and its preview, only when that environment exists — the pull page's
914 // door, `previewCard`, reads the same `appView`.
915 const view = appView(ctx.app, user, repo);

Good: appView gates on requireReader and filters environments by actor. This is the same source as previewCard, so this door shows no preview the page would hide.

916 const env = view.ok
917 ? view.value.environments.find((e) => e.env === previewEnv(pull.number))
outdated

This builds a preview URL whether or not pull-N is deployed. A fork head waiting for approval, a deploy still running or a cancelled deploy has no preview, so the link goes nowhere. Gate it the way previewCard does: only when appView(ctx.app, user, repo) lists an environment previewEnv(pull.number).

918 : undefined;
919 return {
920 ...answer,
921 card: {
922 repo: `${repo.ownerName}/${repo.name}`,
923 ...(env === undefined ? {} : { preview: env.url }),
924 },
925 };
909926 },
910927 },
911928 {
@@ -1152,7 +1169,7 @@ const TOOLS: readonly Tool[] = [
11521169 {
11531170 name: "asks_act",
11541171 description:
1155 "Decide asks (`items`: `asks` ids, `issue:<id>`): dismiss, decline, ship (never undone), close (`reason`), mute, replan.",
1172 "Decide asks (`items`: `asks` ids, `issue:<id>`): dismiss, decline, ship (final), close (`reason`), mute, replan.",
11561173 inputSchema: {
11571174 type: "object",
11581175 properties: {
@@ -2672,7 +2689,7 @@ const TOOLS: readonly Tool[] = [
26722689 {
26732690 name: "propose_to_parent_forge",
26742691 description:
2675 "Offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers that pull.",
2692 "Offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers it.",
26762693 inputSchema: {
26772694 type: "object",
26782695 properties: { repo: REPO_ARG },
@@ -4088,7 +4105,7 @@ const TOOLS: readonly Tool[] = [
40884105 {
40894106 name: "access",
40904107 description:
4091 "What outside agents tried: redacted log of non-browser requests to public paths, newest first, max 500. Forge admin.",
4108 "Outside agents' non-browser requests to public paths: redacted, newest first, max 500. Forge admin.",
40924109 inputSchema: {
40934110 type: "object",
40944111 properties: {
@@ -4167,7 +4184,7 @@ const TOOLS: readonly Tool[] = [
41674184 {
41684185 name: "flow",
41694186 description:
4170 "The pipeline (forge admin): per-stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing` by reviewer.",
4187 "The pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.",
41714188 inputSchema: { type: "object", properties: {} },
41724189 writes: false,
41734190 run(ctx, user) {
@@ -4405,7 +4422,7 @@ const TOOLS: readonly Tool[] = [
44054422 {
44064423 name: "quotas",
44074424 description:
4408 "Per-account repository and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears), plans.",
4425 "Per-account repo and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears), plans.",
44094426 inputSchema: {
44104427 type: "object",
44114428 properties: {
@@ -4619,8 +4636,13 @@ function adminNote(ctx: Ctx, user: User): string {
46194636 }
46204637
46214638 /** A card's model beside the text, for a host that draws `ui://` resources. */
4622 function cardResult(name: string, value: unknown, origin: string): Record<string, unknown> {
4623 const card = cardFor(name, value, origin);
4639 function cardResult(
4640 name: string,
4641 value: unknown,
4642 origin: string,
4643 extra?: Record<string, unknown>,
4644 ): Record<string, unknown> {
4645 const card = cardFor(name, value, origin, extra);
46244646 return card === undefined ? {} : { structuredContent: { card } };
46254647 }
46264648
@@ -4723,7 +4745,7 @@ function rpcOne(
47234745 answer.ok
47244746 ? {
47254747 content: [{ type: "text", text: JSON.stringify(answer.value) }],
4726 ...cardResult(String(params["name"]), answer.value, ctx.url.origin),
4748 ...cardResult(String(params["name"]), answer.value, ctx.url.origin, answer.card),
47274749 }
47284750 : {
47294751 content: [
src/web/studio/cards.ts +44 −3
@@ -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";
13 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull";
1414
1515 export interface CardRow {
1616 readonly label: string;
@@ -36,7 +36,7 @@ export interface Card {
3636
3737 export const CARD_MIME = "text/html;profile=mcp-app";
3838 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
39 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed"];
39 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull"];
4040
4141 /** The tool whose answer each card draws. */
4242 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -47,6 +47,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4747 map: "map",
4848 feed: "feed",
4949 new_app: "app",
50 pull: "pull",

This maps pull to a card, but docs/MCP.md does not say so yet. Its Cards paragraph (~line 212) and its pull row (line 37) need to name ui://forge/pull and what the card shows.

5051 };
5152
5253 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -56,6 +57,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
5657 wave: "Wave",
5758 map: "Plan",
5859 feed: "Feed",
60 pull: "Pull",
5961 };
6062
6163 type Obj = Record<string, unknown>;
@@ -68,7 +70,12 @@ const LIMIT = 12;
6870 const FEED_LIMIT = 10;
6971
7072 /** The card for a tool's answer, or nothing when the answer has no card. */
71 export function cardFor(tool: string, value: unknown, origin = ""): Card | undefined {
73 export function cardFor(
74 tool: string,
75 value: unknown,
76 origin = "",
77 extra: Record<string, unknown> = {},
78 ): Card | undefined {
7279 const kind = CARD_OF_TOOL[tool];
7380 if (kind === undefined) return undefined;
7481 const rows: CardRow[] = [];
@@ -88,6 +95,40 @@ export function cardFor(tool: string, value: unknown, origin = ""): Card | undef
8895 actions.push({ label: `Tell ${repo}`, tool: "tell", args: { repo, context: {} }, words: true });
8996 return { kind, title: "New app", status, rows, actions };
9097 }
98 if (kind === "pull") {
99 const p = obj(value);
100 const web = (u: string): boolean => /^https?:\/\//.test(u);
101 const state = text(p["state"]);
102 const check = text(obj(p["check"])["state"]);
103 const checkWord =
104 check === "ok" ? "check green"
105 : check === "failed" ? "check red"
106 : check === "queued" || check === "running" ? "check running"
107 : check === "cancelled" ? "check cancelled"
108 : "";
109 const conflicts = list(p["conflicts"]).map(text).filter((c) => c !== "");
110 status = [
111 state,
112 state === "open" ? checkWord : "",
113 state === "open" && conflicts.length > 0 ? "conflicts" : "",
114 ]
115 .filter((w) => w !== "")
116 .join(" · ");
117 const number = text(p["number"]);
118 const slug = text(extra["repo"]);
119 rows.push({
120 label: "Branches",
121 value: `${text(p["head"])} → ${text(p["base"])}`,
122 ...(web(origin) && /^[^/]+\/[^/]+$/.test(slug) && number !== ""
123 ? { link: `${origin}/${slug}/pulls/${number}` }
124 : {}),
125 });
126 if (conflicts.length > 0)
127 rows.push({ label: "Conflicts", value: conflicts.slice(0, LIMIT).join(", ") });
128 const preview = text(extra["preview"]);
129 if (web(preview)) rows.push({ label: "Preview", value: preview, link: preview });
130 return { kind, title: `#${number} ${text(p["title"])}`.trim(), status, rows, actions };
131 }
91132 if (kind === "app") {
92133 const apps = list(value).map(obj);
93134 status = `${apps.length} app${apps.length === 1 ? "" : "s"}`;
test/mcp.test.ts +72
@@ -1105,6 +1105,7 @@ describe("the forge over MCP", () => {
11051105 "ui://forge/decision",
11061106 "ui://forge/feed",
11071107 "ui://forge/map",
1108 "ui://forge/pull",
11081109 "ui://forge/run",
11091110 "ui://forge/wave",
11101111 ]);
@@ -1124,6 +1125,77 @@ describe("the forge over MCP", () => {
11241125 expect(meta("milestones")).toBe("ui://forge/wave");
11251126 expect(meta("map")).toBe("ui://forge/map");
11261127 expect(meta("feed")).toBe("ui://forge/feed");
outdated

This test calls cardFor directly, so it passes even if rpcOne never forwards answer.card. Add a tools/call pull round trip that checks structuredContent.card (Branches link, no Preview row when the repository is not an app) and that the text content is unchanged.

1128 expect(meta("pull")).toBe("ui://forge/pull");
1129 });
1130
1131 test("tools/call pull carries the card beside an unchanged text answer", async () => {
1132 const branched = await commitToBranch(app, alice, game, {
1133 branch: "card-wire",
1134 message: "a change",
1135 files: [{ path: "card.txt", content: "c\n" }],
1136 });
1137 if (!branched.ok) throw new Error(JSON.stringify(branched.error));
1138 const opened = await openPull(app, alice, game, {
1139 title: "Card wire",
1140 baseBranch: "main",
1141 headBranch: "card-wire",
1142 });
1143 if (!opened.ok) throw new Error(String(opened.error));
1144 const n = opened.value.number;
1145 const { body } = await call(rw, "tools/call", { name: "pull", arguments: { repo: "alice/game", number: n } });
1146 const card = body.result.structuredContent.card;
1147 expect(card.kind).toBe("pull");
1148 expect(card.title).toBe(`#${n} Card wire`);
1149 const branches = card.rows.find((r: any) => r.label === "Branches");
1150 expect(branches.value).toBe("card-wire → main");
1151 expect(branches.link).toBe(`${base}/alice/game/pulls/${n}`);
1152 // No environment for this pull in the test runtime, so no preview to link.
1153 expect(card.rows.some((r: any) => r.label === "Preview")).toBe(false);
1154 const text = JSON.parse(body.result.content[0].text);
1155 expect(text.title).toBe("Card wire");
1156 expect("card" in text).toBe(false);
1157 });
1158
1159 test("the pull card shows title, state, check, branches and the preview; a merged pull has no check or preview", async () => {
1160 const { cardFor } = await import("../src/web/studio/cards.ts");
1161 const origin = "https://forge.test";
1162 const open = cardFor(
1163 "pull",
1164 {
1165 number: 7,
1166 title: "<b>Add</b> a thing",
1167 state: "open",
1168 base: "main",
1169 head: "scout/7",
1170 check: { state: "ok" },
1171 mergeable: false,
1172 conflicts: ["a.ts"],
1173 },
1174 origin,
1175 { repo: "alice/game", preview: "https://p.example/" },
1176 )!;
1177 expect(open.kind).toBe("pull");
1178 expect(open.title).toBe("#7 <b>Add</b> a thing");
1179 expect(open.status).toBe("open · check green · conflicts");
1180 expect(open.rows.find((r) => r.label === "Branches")).toMatchObject({
1181 value: "scout/7 → main",
1182 link: "https://forge.test/alice/game/pulls/7",
1183 });
1184 expect(open.rows.find((r) => r.label === "Preview")?.link).toBe("https://p.example/");
1185 const merged = cardFor(
1186 "pull",
1187 { number: 8, title: "Done", state: "merged", base: "main", head: "x", check: { state: "ok" } },
1188 origin,
1189 { repo: "alice/game" },
1190 )!;
1191 expect(merged.status).toBe("merged");
1192 expect(merged.rows.some((r) => r.label === "Preview")).toBe(false);
1193 const hostile = cardFor("pull", { number: 1, state: "open", check: { state: "running" } }, origin, {
1194 repo: "a/b",
1195 preview: "javascript:alert(1)",
1196 })!;
1197 expect(hostile.status).toBe("open · check running");
1198 expect(hostile.rows.some((r) => r.label === "Preview")).toBe(false);
11271199 });
11281200
11291201 test("the feed card draws one linked row per item, ten at most, escaped as text", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs