Public: anyone can read this; the crew can change it.
MCP `runs` answers with the `ui://forge/run` card, as `now` does #5747
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5320-mcp-runs-answers-with-the-ui-forge-run-c → main 9 commits fork 3 threads · 3 unresolved
3 files
changed +43
−3
src/http/mcp/tools/runs.ts +4 −3
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "runs", |
| 7 | 7 | doc: "`{ repo, limit? }`: the repository's recent runs, newest first (id, kind, state, issue or pull number, started, ended), 1–50, default 20; read as the runs page reads them", |
| 8 | 8 | description: |
| 9 | "A repository's runs, newest first: id, kind, state, issue or pull, started and ended.", | |
| 9 | "A repository's runs, newest first: id, kind, state, issue or pull number, start, end.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
| @@ -15,7 +15,7 @@ export const tool: Tool = { | ||
| 15 | 15 | type: "integer", |
| 16 | 16 | minimum: 1, |
| 17 | 17 | maximum: 50, |
| 18 | description: "1–50, default 20.", | |
| 18 | description: "Default 20.", | |
| 19 | 19 | }, |
| 20 | 20 | }, |
| 21 | 21 | required: ["repo"], |
| @@ -29,7 +29,7 @@ export const tool: Tool = { | ||
| 29 | 29 | if (isAnswer(repo)) return repo; |
| 30 | 30 | const runs = listRepoRuns(ctx.app, user, repo, { limit }); |
| 31 | 31 | if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)"); |
| 32 | return good({ | |
| 32 | const answer = good({ | |
| 33 | 33 | runs: runs.value.map((r) => ({ |
| 34 | 34 | id: r.id, |
| 35 | 35 | kind: r.kind, |
| @@ -40,5 +40,6 @@ export const tool: Tool = { | ||
| 40 | 40 | endedAt: r.finishedAt, |
| 41 | 41 | })), |
| 42 | 42 | }); |
| 43 | return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}` } }; | |
| 43 | 44 | }, |
| 44 | 45 | }; |
src/web/studio/cards.ts +16
| @@ -45,6 +45,7 @@ export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave" | ||
| 45 | 45 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| 46 | 46 | apps: "app", |
| 47 | 47 | now: "run", |
| 48 | runs: "run", | |
| 48 | 49 | asks: "decision", |
| 49 | 50 | milestones: "wave", |
| 50 | 51 | map: "map", |
| @@ -166,6 +167,21 @@ export function cardFor( | ||
| 166 | 167 | for (const a of apps.slice(0, 3)) |
| 167 | 168 | if (text(a["app"]) !== "") |
| 168 | 169 | actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true }); |
| 170 | } else if (tool === "runs") { | |
| 171 | // `runs` answers `{ runs: [{ id, kind, state, number, startedAt, endedAt }] }`, | |
| 172 | // newest first; the repo comes in `extra`; past runs, so the card offers no stop. | |
| 173 | const runs = list(obj(value)["runs"]).map(obj); | |
| 174 | status = runs.length === 0 ? "no runs" : `${runs.length} run${runs.length === 1 ? "" : "s"}`; | |
| 175 | const slug = text(extra["repo"]); | |
| 176 | for (const r of runs.slice(0, LIMIT)) | |
| 177 | rows.push({ | |
outdated
No |
||
| 178 | label: `${text(r["kind"])} ${text(r["id"])}`, | |
| 179 | value: `${r["number"] == null ? "" : `#${text(r["number"])} — `}${text(r["state"])}`, | |
| 180 | ...(web(origin) && slugOf(slug) && RUN_ID.test(text(r["id"])) | |
| 181 | ? { link: `${origin}/${slug}/runs/${text(r["id"])}` } | |
| 182 | : {}), | |
| 183 | }); | |
| 184 | return { kind, title: "Runs", status, rows, actions }; | |
| 169 | 185 | } else if (kind === "run") { |
| 170 | 186 | const runs = list(value).map(obj); |
| 171 | 187 | status = runs.length === 0 ? "nothing running" : `${runs.length} going`; |
test/mcp.test.ts +23
| @@ -1340,6 +1340,29 @@ describe("the forge over MCP", () => { | ||
| 1340 | 1340 | expect(body.result.structuredContent.card.kind).toBe("feed"); |
| 1341 | 1341 | }); |
| 1342 | 1342 | |
| 1343 | test("runs answers with the run card, newest first, twelve rows at most", async () => { | |
| 1344 | const { cardFor } = await import("../src/web/studio/cards.ts"); | |
| 1345 | const runs = Array.from({ length: 15 }, (_, i) => ({ | |
| 1346 | id: `r${i}`, kind: "agent", state: "done", number: i === 0 ? null : i, startedAt: 1, endedAt: 2, | |
| 1347 | })); | |
| 1348 | const id = "01m23vygyffx5hg2f5vm8r74sr"; | |
| 1349 | runs[1]!.id = id; | |
| 1350 | const card = cardFor("runs", { runs }, "https://f.example", { repo: "alice/game" })!; | |
| 1351 | expect(card.kind).toBe("run"); | |
| 1352 | expect(card.title).toBe("Runs"); | |
| 1353 | expect(card.rows[1]!.link).toBe(`https://f.example/alice/game/runs/${id}`); | |
| 1354 | expect(card.rows[0]!.link).toBeUndefined(); | |
| 1355 | const nolink = cardFor("runs", { runs }, "https://f.example", { repo: "alice" })!; | |
| 1356 | expect(nolink.rows[1]!.link).toBeUndefined(); | |
| 1357 | expect(card.rows).toHaveLength(12); | |
| 1358 | expect(card.rows[0]).toEqual({ label: "agent r0", value: "done" }); | |
| 1359 | expect(card.rows[1]).toMatchObject({ label: `agent ${id}`, value: "#1 — done" }); | |
| 1360 | const { body } = await call(rw, "tools/call", { name: "runs", arguments: { repo: "alice/game" } }); | |
| 1361 | expect(body.result.structuredContent.card.kind).toBe("run"); | |
| 1362 | const tools = (await call(rw, "tools/list")).body.result.tools; | |
| 1363 | expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/run"); | |
| 1364 | }); | |
| 1365 | ||
| 1343 | 1366 | test("a tool result carries the card it renders", async () => { |
| 1344 | 1367 | const { body } = await call(rw, "tools/call", { name: "apps", arguments: {} }); |
| 1345 | 1368 | const card = body.result.structuredContent.card; |
new above
offline
This branch falls through to the shared return, so the card is titled
CARD_TITLE.run= "Working now", but these rows are past runs (done, failed). Return here with its own title, e.g.return { kind, title: "Runs", status, rows, actions };.