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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5318-mcp-pull-answers-with-a-ui-forge-pull-ca → main 5 commits fork 4 threads · 4 unresolved
docs/MCP.md +3 −3
| @@ -35,7 +35,7 @@ for a tunnel and a forge that sleeps. | ||
| 35 | 35 | | `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` | | |
| 36 | 36 | | `say` | speak on an issue or a pull; a working builder hears it as its next step | yes | |
| 37 | 37 | | `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 | | | |
| 39 | 39 | | `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 | |
| 40 | 40 | | `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 | |
| 41 | 41 | | `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 | ||
| 223 | 223 | |
| 224 | 224 | ## Cards, the contract and the plugins (#1116) |
| 225 | 225 | |
| 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 | |
| 227 | 227 | `_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 | |
| 229 | 229 | its ready, working and waiting counts, and the first five ready issues linked |
| 230 | 230 | to their pages; it has no actions. `resources/list` and `resources/read` serve one small |
| 231 | 231 | 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"; | ||
| 161 | 161 | import { CREATE_ERRORS, DELETE_ERRORS } from "../web/pages/branches.ts"; |
| 162 | 162 | import { askToLiveFor } from "../web/ask_to_live.ts"; |
| 163 | 163 | import { getAppByRepo } from "../core/apps.ts"; |
| 164 | import { previewEnv } from "../apps/data.ts"; | |
| 164 | 165 | import { |
| 165 | 166 | ARCHIVED_WHY, |
| 166 | 167 | canAdmin, |
| @@ -192,6 +193,7 @@ import { | ||
| 192 | 193 | } from "../crew/direct.ts"; |
| 193 | 194 | import { createLabel, deleteLabel, setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts"; |
| 194 | 195 | import { |
| 196 | appView, | |
| 195 | 197 | deleteAppEnvVar, |
| 196 | 198 | listAppEnv, |
| 197 | 199 | newApp, |
| @@ -271,7 +273,8 @@ const server = () => ({ name: "forge", version: running().version }); | ||
| 271 | 273 | |
| 272 | 274 | type Args = Record<string, unknown>; |
| 273 | 275 | 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 }; | |
| 275 | 278 | |
| 276 | 279 | const good = (value: unknown): Answer => ({ ok: true, value }); |
| 277 | 280 | const attachWords = (error: string): string => |
| @@ -895,7 +898,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 895 | 898 | pull.state === "open" && |
| 896 | 899 | pull.mergedAt === null && |
| 897 | 900 | checkStale(check.run, await resolveOid(dir, `refs/heads/${pull.baseBranch}`)); |
| 898 | return good({ | |
| 901 | const answer = good({ | |
| 899 | 902 | number: pull.number, |
| 900 | 903 | title: pull.title, |
| 901 | 904 | state: pull.mergedAt !== null ? "merged" : pull.state, |
| @@ -906,6 +909,20 @@ const TOOLS: readonly Tool[] = [ | ||
| 906 | 909 | checkStale: stale, |
| 907 | 910 | ...(state ?? {}), |
| 908 | 911 | }); |
| 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); | |
| 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 |
||
| 918 | : undefined; | |
| 919 | return { | |
| 920 | ...answer, | |
| 921 | card: { | |
| 922 | repo: `${repo.ownerName}/${repo.name}`, | |
| 923 | ...(env === undefined ? {} : { preview: env.url }), | |
| 924 | }, | |
| 925 | }; | |
| 909 | 926 | }, |
| 910 | 927 | }, |
| 911 | 928 | { |
| @@ -1152,7 +1169,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1152 | 1169 | { |
| 1153 | 1170 | name: "asks_act", |
| 1154 | 1171 | 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.", | |
| 1156 | 1173 | inputSchema: { |
| 1157 | 1174 | type: "object", |
| 1158 | 1175 | properties: { |
| @@ -2672,7 +2689,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2672 | 2689 | { |
| 2673 | 2690 | name: "propose_to_parent_forge", |
| 2674 | 2691 | 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.", | |
| 2676 | 2693 | inputSchema: { |
| 2677 | 2694 | type: "object", |
| 2678 | 2695 | properties: { repo: REPO_ARG }, |
| @@ -4088,7 +4105,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4088 | 4105 | { |
| 4089 | 4106 | name: "access", |
| 4090 | 4107 | 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.", | |
| 4092 | 4109 | inputSchema: { |
| 4093 | 4110 | type: "object", |
| 4094 | 4111 | properties: { |
| @@ -4167,7 +4184,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4167 | 4184 | { |
| 4168 | 4185 | name: "flow", |
| 4169 | 4186 | 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`.", | |
| 4171 | 4188 | inputSchema: { type: "object", properties: {} }, |
| 4172 | 4189 | writes: false, |
| 4173 | 4190 | run(ctx, user) { |
| @@ -4405,7 +4422,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4405 | 4422 | { |
| 4406 | 4423 | name: "quotas", |
| 4407 | 4424 | 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.", | |
| 4409 | 4426 | inputSchema: { |
| 4410 | 4427 | type: "object", |
| 4411 | 4428 | properties: { |
| @@ -4619,8 +4636,13 @@ function adminNote(ctx: Ctx, user: User): string { | ||
| 4619 | 4636 | } |
| 4620 | 4637 | |
| 4621 | 4638 | /** 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); | |
| 4624 | 4646 | return card === undefined ? {} : { structuredContent: { card } }; |
| 4625 | 4647 | } |
| 4626 | 4648 | |
| @@ -4723,7 +4745,7 @@ function rpcOne( | ||
| 4723 | 4745 | answer.ok |
| 4724 | 4746 | ? { |
| 4725 | 4747 | 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), | |
| 4727 | 4749 | } |
| 4728 | 4750 | : { |
| 4729 | 4751 | content: [ |
src/web/studio/cards.ts +44 −3
| @@ -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"; | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull"; | |
| 14 | 14 | |
| 15 | 15 | export interface CardRow { |
| 16 | 16 | readonly label: string; |
| @@ -36,7 +36,7 @@ export interface Card { | ||
| 36 | 36 | |
| 37 | 37 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 38 | 38 | 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"]; | |
| 40 | 40 | |
| 41 | 41 | /** The tool whose answer each card draws. */ |
| 42 | 42 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -47,6 +47,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 47 | 47 | map: "map", |
| 48 | 48 | feed: "feed", |
| 49 | 49 | new_app: "app", |
| 50 | pull: "pull", | |
This maps |
||
| 50 | 51 | }; |
| 51 | 52 | |
| 52 | 53 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -56,6 +57,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 56 | 57 | wave: "Wave", |
| 57 | 58 | map: "Plan", |
| 58 | 59 | feed: "Feed", |
| 60 | pull: "Pull", | |
| 59 | 61 | }; |
| 60 | 62 | |
| 61 | 63 | type Obj = Record<string, unknown>; |
| @@ -68,7 +70,12 @@ const LIMIT = 12; | ||
| 68 | 70 | const FEED_LIMIT = 10; |
| 69 | 71 | |
| 70 | 72 | /** 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 { | |
| 72 | 79 | const kind = CARD_OF_TOOL[tool]; |
| 73 | 80 | if (kind === undefined) return undefined; |
| 74 | 81 | const rows: CardRow[] = []; |
| @@ -88,6 +95,40 @@ export function cardFor(tool: string, value: unknown, origin = ""): Card | undef | ||
| 88 | 95 | actions.push({ label: `Tell ${repo}`, tool: "tell", args: { repo, context: {} }, words: true }); |
| 89 | 96 | return { kind, title: "New app", status, rows, actions }; |
| 90 | 97 | } |
| 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 | } | |
| 91 | 132 | if (kind === "app") { |
| 92 | 133 | const apps = list(value).map(obj); |
| 93 | 134 | status = `${apps.length} app${apps.length === 1 ? "" : "s"}`; |
test/mcp.test.ts +72
| @@ -1105,6 +1105,7 @@ describe("the forge over MCP", () => { | ||
| 1105 | 1105 | "ui://forge/decision", |
| 1106 | 1106 | "ui://forge/feed", |
| 1107 | 1107 | "ui://forge/map", |
| 1108 | "ui://forge/pull", | |
| 1108 | 1109 | "ui://forge/run", |
| 1109 | 1110 | "ui://forge/wave", |
| 1110 | 1111 | ]); |
| @@ -1124,6 +1125,77 @@ describe("the forge over MCP", () => { | ||
| 1124 | 1125 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| 1125 | 1126 | expect(meta("map")).toBe("ui://forge/map"); |
| 1126 | 1127 | expect(meta("feed")).toBe("ui://forge/feed"); |
outdated
This test calls |
||
| 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); | |
| 1127 | 1199 | }); |
| 1128 | 1200 | |
| 1129 | 1201 | test("the feed card draws one linked row per item, ten at most, escaped as text", async () => { |
Good:
appViewgates onrequireReaderand filters environments by actor. This is the same source aspreviewCard, so this door shows no preview the page would hide.