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

MCP restore_drill, scorecard, reports and usage draw ui:// cards, as held and board do #11503

Merged opened this

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

Merged

torque/forge:torque/11475-mcp-restore-drill-scorecard-reports-and → main 5 commits fork 1 thread · 1 unresolved

  • Merged. torque/11475-mcp-restore-drill-scorecard-reports-and was merged into main as 45a2333.

Review

Sign in to review this change.
8 files changed +243 −20
docs/MCP.md +7 −4
@@ -179,8 +179,8 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
179179 | `repo` | — | One repository you can read, as GET /api/v1/repos/:owner/:name | 223 |
180180 | `repo_labels` | — | Repo labels: list (default), create, update, remove (admin token) | 351 |
181181 | `repo_settings` | — | Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams | 444 |
182 | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 258 |
183 | `restore_drill` | — | Drill-restore the newest snapshot | 123 |
182 | `reports` | `accept`, `dismiss` | Outsiders' reports; `id`+`action`: accept or dismiss | 310 |
183 | `restore_drill` | — | Drill-restore the newest snapshot | 173 |
184184 | `returns` | — | What crew tokens bought in `days` (1–90, default 7): dollars, runs, landed, cost/change | 284 |
185185 | `review` | — | Approve a pull, or request changes (`reason` required); your own pull is refused | 350 |
186186 | `rollback` | — | Plan, then apply, a rollback to a kept release (admin) | 209 |
@@ -222,7 +222,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
222222 | `tokens` | — | Your API tokens, no secrets | 146 |
223223 | `trail` | — | A migrated repository's source and forks; `shown` shows or hides them (admin) | 258 |
224224 | `upstream` | — | Read, set (`enabled`) or `check` the upstream a repository follows | 302 |
225 | `usage` | — | Crew spend: with `repo`, `days` (1–90) by agent, issue; without, today, last24h, headroom % | 295 |
225 | `usage` | — | Crew spend: with `repo`, `days` (1–90) by agent, issue; without, today, last24h, headroom % | 345 |
226226 | `valves` | — | The week's pipes and valves; with `repo` and `level`, turns a valve (plans first) | 269 |
227227 | `variant` | — | Like or dislike a pull among its variants; a later vote replaces; dislike closes it | 349 |
228228 | `vision` | — | VISION.md: purpose, order, rules | 235 |
@@ -527,7 +527,7 @@ answers
527527 | `ui://forge/explore` | `explore` | Apps, people and forges ranked, at most 20: name, kind, the numbers (and likes or ship time when sorted by them), linked; only what you may see. With `kind: tree`: upstream, this and fork rows, each with how it follows and linked to its repository. |
528528 | `ui://forge/issue` | `issue` | One issue: title, state, assignee, labels, what it waits for. |
529529 | `ui://forge/flow` | `flow` | The pipeline: per stage counts, oldest waiting, the bottleneck. |
530 | `ui://forge/scorecard` | `scorecard` | The crew's scorecard per agent (forge admin). |
530 | `ui://forge/scorecard` | `scorecard` | The crew's scorecard, one row per agent: landed and red rate all time, then the week (forge admin). |
531531 | `ui://forge/crew` | `now` | What the crew is doing right now, with Stop buttons. |
532532 | `ui://forge/board` | `boards` | One board's panels (person admins). |
533533 | `ui://forge/forks` | `fork_tree`, `forks` | The fork tree around a repository, each row with how it follows; `forks` a row per fork with its commits ahead and behind, `kind: chain` its chain (a severed chain says so); each linked to the repository, at most 30 and one More row. |
@@ -538,6 +538,9 @@ answers
538538 | `ui://forge/releases` | `releases` with `shipped` | What shipped: one linked row per landing, the live version, what is on its way. |
539539 | `ui://forge/forges` | `forges` (listing, admin) | Forges you sent: one linked row each, whom it is for and its stage in words (sent, opened, signed in, last tell); a link unopened for 3 days says `resend` and comes first. |
540540 | `ui://forge/held` | `held` | What waits on a person: themes, one row each, and one button that closes the done ones. |
541 | `ui://forge/drill` | `restore_drill` | The drill: verdict in one word (green, red, none), the failed step, and when it ran. |
542 | `ui://forge/reports` | `reports` (listing) | Waiting reports from outsiders: one row each, its state and who filed it, linked to the repository's reports. |
543 | `ui://forge/usage` | `usage` | The window's figures as rows: runs, cost and tokens, by agent for a repo, or today and last 24 hours with each account's headroom. |
541544
542545 The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with
543546 its ready, working and waiting counts, and the first five ready issues linked
src/http/mcp/tools/reports.ts +12 −9
@@ -52,15 +52,18 @@ export const tool: Tool = {
5252 const byAgent = byFiler(waiting.value);
5353 const n = waiting.value.length;
5454 const counts = byAgent.map((g) => `${g.count} ${filerWords(g)}`).join("; ");
55 return good({
56 says:
57 n === 0
58 ? "No reports are waiting."
59 : `${n} report${n === 1 ? "" : "s"} waiting (${counts}); ` +
60 "their words are an outsider's, unchecked.",
61 byAgent,
62 items: reportViews(ctx.app, repo, waiting.value),
63 });
55 return {
56 ...good({
57 says:
58 n === 0
59 ? "No reports are waiting."
60 : `${n} report${n === 1 ? "" : "s"} waiting (${counts}); ` +
61 "their words are an outsider's, unchecked.",
62 byAgent,
63 items: reportViews(ctx.app, repo, waiting.value),
64 }),
65 card: { repo: `${repo.ownerName}/${repo.name}` },
66 };
6467 }
6568 if (action === "dismiss") {
6669 const done = dismissReport(ctx.app, user, repo, id as string);
src/http/mcp/tools/restore_drill.ts +5 −3
@@ -18,9 +18,11 @@ export const tool: Tool = {
1818 return good({ verdict: "none", says: "This forge has no snapshots to drill." });
1919 try {
2020 const d = await svc.drill();
21 return d.verdict === "green"
22 ? good({ verdict: "green", says: "The drill restored green." })
23 : good({ verdict: "red", failedStep: d.failedStep, says: `The drill failed at ${d.failedStep}.` });
21 const answer =
22 d.verdict === "green"
23 ? good({ verdict: "green", says: "The drill restored green." })
24 : good({ verdict: "red", failedStep: d.failedStep, says: `The drill failed at ${d.failedStep}.` });
25 return { ...answer, card: { at: d.at } };
2426 } catch (error) {
2527 return good({ verdict: "none", says: `The drill could not run: ${String(error)}` });
2628 }
src/web/studio/cards.ts +60 −3
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held";
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues" | "forges" | "held" | "drill" | "reports" | "usage";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4444
4545 export const CARD_MIME = "text/html;profile=mcp-app";
4646 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges"];
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues", "held", "forges", "drill", "reports", "usage"];
4848
4949 /** The tool whose answer each card draws. */
5050 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -74,6 +74,9 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
7474 issues: "issues",
7575 forges: "forges",
7676 held: "held",
77 restore_drill: "drill",
78 reports: "reports",
79 usage: "usage",
7780 };
7881
7982 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -101,6 +104,9 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
101104 issues: "Issues",
102105 forges: "Forges you sent",
103106 held: "Later",
107 drill: "Restore drill",
108 reports: "Reports",
109 usage: "Spend",
104110 };
105111
106112 type Obj = Record<string, unknown>;
@@ -124,6 +130,8 @@ const PULLS_LIMIT = 20;
124130 export const SEARCH_ROWS = 20;
125131 export const ISSUES_ROWS = 20;
126132 const HELD_ROWS = 40;
133 const REPORT_ROWS = 30;
134 const USAGE_ROWS = 20;
127135 const ASKED_WORDS = 80;
128136 const ASKED_LIMIT = 20;
129137 const RELEASE_ROWS = 10;
@@ -296,6 +304,52 @@ export function cardFor(
296304 actions.push({ label: `Close ${approve.length} done`, tool: "held", args: { app: slug, apply: approve, digest: text(v["digest"]) } });
297305 return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions };
298306 }
307 if (kind === "drill") {
308 // `restore_drill` answers `{ verdict: green | red | none, failedStep?, says }`; `extra.at` is when it ran.
309 const v = obj(value);
310 const word = text(v["verdict"]);
311 if (word !== "green" && word !== "red" && word !== "none") return undefined;
312 rows.push({ label: "Verdict", value: word });
313 if (word === "red" && text(v["failedStep"]) !== "") rows.push({ label: "Failed step", value: text(v["failedStep"]) });
314 const at = text(extra["at"]);
315 if (at !== "" && !Number.isNaN(Date.parse(at))) rows.push({ label: "When", value: timeAgo(at) });
316 return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions };
317 }
318 if (kind === "reports") {
319 // The reports list answers `{ says, byAgent, items: [{ title, state, filedBy }] }`; an accept or dismiss has no `items`.
320 const v = obj(value);
321 if (!Array.isArray(v["items"])) return undefined;
322 const slug = text(extra["repo"]);
323 const link = web(origin) && slugOf(slug) && here(origin, `/${slug}/reports`) ? { link: `${origin}/${slug}/reports` } : {};
324 const items = v["items"].map(obj);
325 for (const r of items.slice(0, REPORT_ROWS))
326 rows.push({ label: text(r["title"]) || "(untitled)", value: `${text(r["state"])} · ${text(r["filedBy"])}`, ...link });
327 if (items.length > REPORT_ROWS) rows.push({ label: "More", value: `${items.length - REPORT_ROWS} more not shown` });
328 return { kind, title: CARD_TITLE[kind], status: text(v["says"]), rows, actions };
329 }
330 if (kind === "usage") {
331 // Two answers: a repo's `{ app, days, total, byAgent }`, or the forge's `{ today, last24h, subscriptions }`.
332 const v = obj(value);
333 const span = (s: Obj): string =>
334 `${Number(s["runs"]) || 0} runs · $${(Number(s["costUsd"]) || 0).toFixed(2)} · ${(Number(s["tokensIn"]) || 0) + (Number(s["tokensOut"]) || 0)} tokens`;
335 if (Array.isArray(v["byAgent"]) && text(v["app"]) !== "") {
336 rows.push({ label: "Total", value: span(obj(v["total"])) });
337 const agents = v["byAgent"].map(obj);
338 for (const a of agents.slice(0, USAGE_ROWS)) rows.push({ label: text(a["agent"]) || "no agent", value: span(a), depth: 1 });
339 if (agents.length > USAGE_ROWS) rows.push({ label: "More", value: `${agents.length - USAGE_ROWS} more not shown` });
340 return { kind, title: CARD_TITLE[kind], status: `${text(v["app"])} · ${text(v["days"])} days`, rows, actions };
341 }
342 if (!Array.isArray(v["subscriptions"])) return undefined;
343 rows.push({ label: "Today", value: span(obj(v["today"])) });
344 rows.push({ label: "Last 24 hours", value: span(obj(v["last24h"])) });
345 const subs = v["subscriptions"].map(obj);
346 for (const s of subs.slice(0, USAGE_ROWS)) {
347 const room = list(s["headroom"]).map(obj).map((h) => `${text(h["window"])} ${h["pct"] === null ? "?" : `${text(h["pct"])}%`}`).join(" · ");
348 rows.push({ label: text(s["subscription"]), value: `${span(obj(s["today"]))} today${room === "" ? "" : ` · headroom ${room}`}`, depth: 1 });
349 }
350 if (subs.length > USAGE_ROWS) rows.push({ label: "More", value: `${subs.length - USAGE_ROWS} more not shown` });
351 return { kind, title: CARD_TITLE[kind], status: `${Number(v["apps"]) || 0} apps`, rows, actions };
352 }
299353 if (tool === "forks" || (tool === "explore" && Array.isArray(obj(value)["upstream"]))) {
300354 // Each answer has its own helper; the card kind stays the tool's (`forks` or `explore`).
301355 const v = obj(value);
@@ -394,7 +448,10 @@ export function cardFor(
394448 const crewStatus = `${week} landed this week`;
395449 for (const a of agents.slice(0, LIMIT)) {
396450 const w = obj(a["week"]);
397 const wk = `${Number(w["merged"]) || 0} merged · ${Number(w["returned"]) || 0} returned · $${(Number(w["cost"]) || 0).toFixed(2)} this week`;
451 const all = obj(a["all"]);
452 const red = typeof all["redCheckRate"] === "number" ? ` · ${Math.round((all["redCheckRate"] as number) * 100)}% red` : "";
453 const landed = a["all"] === null ? "" : `${Number(all["landed"]) || 0} landed${red} · `;
454 const wk = `${landed}${Number(w["merged"]) || 0} merged · ${Number(w["returned"]) || 0} returned · $${(Number(w["cost"]) || 0).toFixed(2)} this week`;
398455 rows.push({ label: text(a["username"]), value: wk });
399456 }
400457 // The card shows LIMIT agents; the rest are counted, never dropped silently.
test/mcp-flow-card.test.ts +1 −1
@@ -105,7 +105,7 @@ describe("MCP flow and crew cards", () => {
105105 const body = await rpc("tools/call", { name: "scorecard", arguments: {} });
106106 const drawn = body.result.structuredContent.card;
107107 expect(drawn.kind).toBe("scorecard");
108 expect(drawn.rows).toEqual([{ label: "smith", value: "1 merged · 0 returned · $0.00 this week" }]);
108 expect(drawn.rows).toEqual([{ label: "smith", value: "1 landed · 100% red · 1 merged · 0 returned · $0.00 this week" }]);
109109 const card = cardFor("scorecard", {
110110 agents: [
111111 { username: "scout", all: null, recent: { landed: 5, redCheckRate: 0.25 } },
test/mcp-ops-cards.test.ts added +58
@@ -0,0 +1,58 @@
1 /** restore_drill, scorecard, reports and usage answer with a card beside their unchanged JSON. */
2 import { expect, test } from "bun:test";
3 import { CARD_OF_TOOL, cardFor } from "../src/web/studio/cards.ts";
4
5 test("restore_drill: verdict in one word, the failed step, when", () => {

These tests never touch the tools. Add tools/call tests so that dropping card: { at } in restore_drill.ts or card: { repo } in reports.ts fails a test.

6 expect(CARD_OF_TOOL["restore_drill"]).toBe("drill");
7 const at = new Date(Date.now() - 3 * 3600_000).toISOString();
8 const red = cardFor("restore_drill", { verdict: "red", failedStep: "<b>boot</b>", says: "The drill failed at boot." }, "", { at })!;
9 expect(red.rows).toEqual([
10 { label: "Verdict", value: "red" },
11 { label: "Failed step", value: "<b>boot</b>" },
12 { label: "When", value: "3h ago" },
13 ]);
14 const green = cardFor("restore_drill", { verdict: "green", says: "ok" })!;
15 expect(green.rows).toEqual([{ label: "Verdict", value: "green" }]);
16 expect(cardFor("restore_drill", { nope: 1 })).toBeUndefined();
17 });
18
19 test("scorecard: one row per member, the landed number and red rate beside the week", () => {
20 const agents = [
21 { username: "torque", all: { landed: 12, redCheckRate: 0.25 }, recent: null, week: { merged: 3, returned: 1, cost: 4.5 } },
22 { username: "idle", all: null, recent: null, week: { merged: 0, returned: 0, cost: 0 } },
23 ];
24 const card = cardFor("scorecard", { agents, keeperDays: [] })!;
25 expect(card.rows).toEqual([
26 { label: "torque", value: "12 landed · 25% red · 3 merged · 1 returned · $4.50 this week" },
27 { label: "idle", value: "0 merged · 0 returned · $0.00 this week" },
28 ]);
29 });
30
31 test("reports: one row per report, its state, linked to the repository's reports", () => {
32 const value = { says: "2 reports waiting", byAgent: [], items: [
33 { id: "a", title: "Broken <link>", state: "waiting", filedBy: "anonymous" },
34 { id: "b", title: "", state: "waiting", filedBy: "signed" },
35 ] };
36 const card = cardFor("reports", value, "https://f.test", { repo: "alice/engine" })!;
37 expect(card.status).toBe("2 reports waiting");
38 expect(card.rows[0]).toEqual({ label: "Broken <link>", value: "waiting · anonymous", link: "https://f.test/alice/engine/reports" });
39 expect(card.rows[1]!.label).toBe("(untitled)");
40 expect(cardFor("reports", { id: "a", state: "dismissed" }, "https://f.test", { repo: "alice/engine" })).toBeUndefined();
41 expect(cardFor("reports", value, "https://f.test", { repo: "nowhere" })!.rows[0]!.link).toBeUndefined();
42 });
43
44 test("usage: the window's figures as rows, for a repo and for the forge", () => {
45 const span = { runs: 2, tokensIn: 100, tokensOut: 50, costUsd: 1.234 };
46 const repo = cardFor("usage", { app: "a/b", days: 7, total: span, byAgent: [{ agent: "torque", ...span }] })!;
47 expect(repo.status).toBe("a/b · 7 days");
48 expect(repo.rows).toEqual([
49 { label: "Total", value: "2 runs · $1.23 · 150 tokens" },
50 { label: "torque", value: "2 runs · $1.23 · 150 tokens", depth: 1 },
51 ]);
52 const forge = cardFor("usage", { apps: 3, today: span, last24h: span, subscriptions: [
53 { subscription: "main", today: span, last24h: span, headroom: [{ window: "5h", pct: 40, resetsAt: null }, { window: "week", pct: null, resetsAt: null }] },
54 ] })!;
55 expect(forge.status).toBe("3 apps");
56 expect(forge.rows[2]!.value).toBe("2 runs · $1.23 · 150 tokens today · headroom 5h 40% · week ?");
57 expect(cardFor("usage", { nope: 1 })).toBeUndefined();
58 });
test/mcp-ops-tool-cards.test.ts added +97
@@ -0,0 +1,97 @@
1 /** restore_drill, reports and usage, called through `tools/call`, carry the card: the tool's `card` extras are what give it its When row and its links. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { fileReport } from "../src/actions/reports.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import type { DrillResult } from "../src/snapshot/drill.ts";
13 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
14 import { dirStore } from "../src/snapshot/store.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let svc: SnapshotsHandle;
23 let token: string;
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-ops-tool-cards-"));
27 app = createApp({ dataDir, port: 0 });
28 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
29 if (!made.ok) throw new Error(made.error);
30 const alice = getUserByName(app.db, "alice")!;
31 token = createToken(app.db, alice.id, "admin", "admin").token;
32 svc = startSnapshots(app, dirStore(join(dataDir, "store")), {
33 version: "t",
34 nightly: false,
35 drill: async (): Promise<DrillResult> => ({
36 at: new Date(Date.now() - 3 * 3600_000).toISOString(),
37 snapshotId: "snap-1",
38 ms: 5,
39 counts: { repositories: 1, issues: 0, users: 1, pulls: 0, apps: 0 },
40 health: "unreached",
41 verdict: "red",
42 failedStep: "unseal",
43 phases: [],
44 budgetMs: 0,
45 reason: "a sealed value does not open",
46 }),
47 });
48 servers = await serve(app);
49 const repo = await createRepo(app, alice, { name: "engine", visibility: "public" });
50 if (!repo.ok) throw new Error(String(repo.error));
51 for (const [i, title] of ["First", "Second"].entries()) {
52 const filed = fileReport(app, repo.value, { title, body: "", page: "" }, `src-${i}`);
53 if (!filed.ok) throw new Error(JSON.stringify(filed.error));
54 }
55 });
56
57 afterAll(async () => {
58 servers.stop();
59 await svc.stop();
60 app.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 const call = async (name: string, args: Record<string, unknown>): Promise<any> => {
65 const res = await fetch(`${servers.url}/mcp`, {
66 method: "POST",
67 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
68 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
69 });
70 return ((await res.json()) as any).result;
71 };
72
73 test("restore_drill: the card has the verdict, the failed step and the When row", async () => {
74 const r = await call("restore_drill", {});
75 expect(r.structuredContent.card.title).toBe("Restore drill");
76 expect(r.structuredContent.card.rows).toEqual([
77 { label: "Verdict", value: "red" },
78 { label: "Failed step", value: "unseal" },
79 { label: "When", value: "3h ago" },
80 ]);
81 });
82
83 test("reports: each row links to the repo's reports page", async () => {
84 const r = await call("reports", { repo: "alice/engine" });
85 const rows = r.structuredContent.card.rows;
86 expect(rows.length).toBe(2);
87 for (const row of rows) expect(row.link).toBe(`${servers.url}/alice/engine/reports`);
88 });
89
90 test("usage: a card for the repo answer and for the forge answer", async () => {
91 const repo = await call("usage", { repo: "alice/engine" });
92 expect(repo.structuredContent.card.title).toBe("Spend");
93 expect(repo.structuredContent.card.rows[0].label).toBe("Total");
94 const forge = await call("usage", {});
95 expect(forge.structuredContent.card.title).toBe("Spend");
96 expect(forge.structuredContent.card.rows.map((x: any) => x.label).slice(0, 2)).toEqual(["Today", "Last 24 hours"]);
97 });
test/mcp.test.ts +3
@@ -1291,6 +1291,7 @@ describe("the forge over MCP", () => {
12911291 "ui://forge/asks",
12921292 "ui://forge/board",
12931293 "ui://forge/crew",
1294 "ui://forge/drill",
12941295 "ui://forge/explore",
12951296 "ui://forge/feed",
12961297 "ui://forge/flow",
@@ -1305,10 +1306,12 @@ describe("the forge over MCP", () => {
13051306 "ui://forge/pulls",
13061307 "ui://forge/release",
13071308 "ui://forge/releases",
1309 "ui://forge/reports",
13081310 "ui://forge/run",
13091311 "ui://forge/runs",
13101312 "ui://forge/scorecard",
13111313 "ui://forge/search",
1314 "ui://forge/usage",
13121315 "ui://forge/wave",
13131316 ]);
13141317 const read = await call(rw, "resources/read", { uri: "ui://forge/app" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs