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

MCP `runs` answers with the `ui://forge/run` card, as `now` does #5747

Merged opened this

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

Merged

scout/forge:scout/5320-mcp-runs-answers-with-the-ui-forge-run-c → main 9 commits fork 3 threads · 3 unresolved

  • Merged. scout/5320-mcp-runs-answers-with-the-ui-forge-run-c was merged into main as 26a66c1.

Review

Sign in to review this change.
3 files changed +43 −3
src/http/mcp/tools/runs.ts +4 −3
@@ -6,7 +6,7 @@ export const tool: Tool = {
66 name: "runs",
77 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",
88 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.",
1010 inputSchema: {
1111 type: "object",
1212 properties: {
@@ -15,7 +15,7 @@ export const tool: Tool = {
1515 type: "integer",
1616 minimum: 1,
1717 maximum: 50,
18 description: "1–50, default 20.",
18 description: "Default 20.",
1919 },
2020 },
2121 required: ["repo"],
@@ -29,7 +29,7 @@ export const tool: Tool = {
2929 if (isAnswer(repo)) return repo;
3030 const runs = listRepoRuns(ctx.app, user, repo, { limit });
3131 if (!runs.ok) return bad("not_found", "no such app or repository (owner/name)");
32 return good({
32 const answer = good({
3333 runs: runs.value.map((r) => ({
3434 id: r.id,
3535 kind: r.kind,
@@ -40,5 +40,6 @@ export const tool: Tool = {
4040 endedAt: r.finishedAt,
4141 })),
4242 });
43 return { ...answer, card: { repo: `${repo.ownerName}/${repo.name}` } };
4344 },
4445 };
src/web/studio/cards.ts +16
@@ -45,6 +45,7 @@ export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave"
4545 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4646 apps: "app",
4747 now: "run",
48 runs: "run",
4849 asks: "decision",
4950 milestones: "wave",
5051 map: "map",
@@ -166,6 +167,21 @@ export function cardFor(
166167 for (const a of apps.slice(0, 3))
167168 if (text(a["app"]) !== "")
168169 actions.push({ label: `Tell ${text(a["app"])}`, tool: "tell", args: { repo: text(a["app"]), context: {} }, words: true });
170 } else if (tool === "runs") {

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 };.

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 link on the row, though the now branch below links each run to its page. Pass repo through extra from tools/runs.ts, as pull does, and add link: ${origin}/${repo}/runs/${id}` guarded by web(origin) && slugOf(repo) && RUN_ID.test(id)`.

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 };
169185 } else if (kind === "run") {
170186 const runs = list(value).map(obj);
171187 status = runs.length === 0 ? "nothing running" : `${runs.length} going`;
test/mcp.test.ts +23
@@ -1340,6 +1340,29 @@ describe("the forge over MCP", () => {
13401340 expect(body.result.structuredContent.card.kind).toBe("feed");
13411341 });
13421342
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
13431366 test("a tool result carries the card it renders", async () => {
13441367 const { body } = await call(rw, "tools/call", { name: "apps", arguments: {} });
13451368 const card = body.result.structuredContent.card;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs