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

The MCP boards tool answers with a `ui://forge/board` card: a board's panels as rows, with the same numbers as the page #11040

Merged opened this

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

Merged

torque/forge:torque/10996-the-mcp-boards-tool-answers-with-a-ui-fo → main 3 commits fork

  • Merged. torque/10996-the-mcp-boards-tool-answers-with-a-ui-fo was merged into main as 19873e7.

Review

Sign in to review this change.
6 files changed +151 −5
docs/MCP.md +1 −1
@@ -75,7 +75,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
7575 | `board_delete` | — | Delete a board by slug (history keeps it) | 194 |
7676 | `board_pins` | — | The panels pinned to the caller's home (person admins) | 302 |
7777 | `board_write` | — | Write a whole board under a slug: {title, range, refresh, panels} | 231 |
78 | `boards` | — | The forge's boards for person admins: slug, title, range, refresh and panels | 425 |
78 | `boards` | — | The forge's boards for person admins: slug, title, range, refresh and panels | 475 |
7979 | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 |
8080 | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 |
8181 | `capacity` | — | Forge capacity and guards, live | 415 |
src/boards/panel_now.ts added +17
@@ -0,0 +1,17 @@
1 /**
2 * A panel's current value as one short string: the number the page draws
3 * large on a number chart — the last group of the panel's rows, through
4 * `formatValue`. Read through `panelData`, the one door the page reads, so
5 * the card and the page cannot disagree. Plain data in, a string out.
6 */
7 import type { Database } from "bun:sqlite";
8 import { formatValue } from "./chart.ts";
9 import { panelData } from "./panel_data.ts";
10 import type { Board, Panel } from "./store.ts";
11
12 export function panelNow(db: Database, board: Board, panel: Panel, now = Date.now()): string {
13 const data = panelData(db, board, panel, {}, now);
14 if (!data.ok) return "unavailable";
15 const last = data.rows[data.rows.length - 1];
16 return last === undefined ? "no data" : formatValue(last.value ?? 0);
17 }
src/http/mcp/tools/boards.ts +6 −2
@@ -1,10 +1,11 @@
11 import { listAllBoards, showBoard } from "../../../actions/boards.ts";
22 import { panelData, parseWindow } from "../../../boards/panel_data.ts";
3 import { panelNow } from "../../../boards/panel_now.ts";
34 import { good, bad, type Tool, str, boardRefused, mayBoards, boardFailed } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "boards",
7 doc: "`{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board; with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step`",
8 doc: "`{ slug?, panel?, from?, to?, step? }` (person admins): without `slug`, lists the forge's boards (slug, title, range, refresh, panels); with `slug`, that board (the `ui://forge/board` card); with `panel` (place from 0, up to 23), that panel's data over the `from`/`to` window and `step`",
89 description:
910 "The forge's boards for person admins: slug, title, range, refresh and panels.",
1011 inputSchema: {
@@ -28,7 +29,10 @@ export const tool: Tool = {
2829 }
2930 const shown = await showBoard(ctx.app, user, str(args["slug"]));
3031 if (!shown.ok) return boardFailed(shown.error);
31 if (args["panel"] === undefined) return good({ slug: str(args["slug"]), ...shown.value });
32 if (args["panel"] === undefined) {
33 const values = shown.value.panels.slice(0, 8).map((p) => panelNow(ctx.app.db, shown.value, p));
34 return { ...good({ slug: str(args["slug"]), ...shown.value }), card: { values } };
35 }
3236 const index = args["panel"];
3337 const panel = typeof index === "number" && Number.isInteger(index) ? shown.value.panels[index] : undefined;
3438 if (!panel) return bad("not_found", "that board has no panel there");
src/web/studio/cards.ts +18 −2
@@ -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" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew";
14 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "crew" | "board";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -40,7 +40,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4040
4141 export const CARD_MIME = "text/html;profile=mcp-app";
4242 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
43 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew"];
43 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "crew", "board"];
4444
4545 /** The tool whose answer each card draws. */
4646 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -60,6 +60,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
6060 issue: "issue",
6161 flow: "flow",
6262 scorecard: "crew",
63 boards: "board",
6364 };
6465
6566 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
@@ -76,6 +77,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
7677 issue: "Issue",
7778 flow: "Flow",
7879 crew: "Crew",
80 board: "Board",
7981 };
8082
8183 type Obj = Record<string, unknown>;
@@ -89,6 +91,8 @@ const FEED_LIMIT = 10;
8991 const PULLS_LIMIT = 20;
9092 const ASKED_WORDS = 80;
9193 const ASKED_LIMIT = 20;
94 /** A board card shows this many panels, then counts the rest. */
95 const BOARD_PANELS = 8;
9296 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
9397 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
9498
@@ -158,6 +162,18 @@ export function cardFor(
158162 }
159163 return { kind, title: CARD_TITLE[kind], status: verdict(typed, now), rows, actions };
160164 }
165 if (kind === "board") {
166 // One board's answer is `{ slug, title, range, refresh, panels: [{ title, … }] }`; the list and a
167 // panel's data have no `panels`, so no card. `extra.values` are the panels' current values, in order.
168 const v = obj(value);
169 if (!Array.isArray(v["panels"]) || text(v["title"]) === "") return undefined;
170 const panels = v["panels"].map(obj);
171 const values = list(extra["values"]).map(text);
172 for (const [i, p] of panels.slice(0, BOARD_PANELS).entries())
173 rows.push({ label: text(p["title"]), value: values[i] ?? "no data" });
174 if (panels.length > BOARD_PANELS) rows.push({ label: "More", value: `${panels.length - BOARD_PANELS} more` });
175 return { kind, title: text(v["title"]), status: `${panels.length} panel${panels.length === 1 ? "" : "s"} · ${text(v["range"])}`, rows, actions };
176 }
161177 if (kind === "crew") {
162178 // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`.
163179 const v = obj(value);
test/mcp-board-card.test.ts added +107
@@ -0,0 +1,107 @@
1 /** MCP `boards` with a `slug` draws the `ui://forge/board` card: the page's numbers, escaped, at most 8 panels. */
2 import { afterAll, beforeAll, describe, 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 { writeBoard, showBoard } from "../src/actions/boards.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { drawPanel } from "../src/web/pages/boards-view.ts";
14 import { CARD_OF_TOOL, cardFor } from "../src/web/studio/cards.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const panel = (title: string, aggregate = "count") => ({ title, chart: "number", query: { source: "runs", aggregate }, w: 1, h: 1 });
20
21 describe("MCP boards card", () => {
22 let dataDir = "";
23 let app: App;
24 let servers: Servers;
25 let token = "";
26
27 const call = async (args: unknown) => {
28 const res = await fetch(`${servers.url}/mcp`, {
29 method: "POST",
30 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
31 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "boards", arguments: args } }),
32 });
33 return ((await res.json()) as any).result;
34 };
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-board-card-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
41 if (!a.ok) throw new Error("users");
42 token = createToken(app.db, a.value.id, "admin", "admin").token;
43 // Runs in a public repository and in a private one: the page reads both for an admin, so the card must too.
44 for (const [name, visibility] of [["open", "public"], ["closed", "private"]] as const) {
45 const repo = await createRepo(app, a.value, { name, visibility });
46 if (!repo.ok) throw new Error(name);
47 const issue = createIssue(app.db, { repoId: repo.value.id, authorId: a.value.id, title: "t" });
48 if (!issue.ok) throw new Error("issue");
49 app.db.query(`INSERT INTO run (id, repo_id, issue_id, kind, actor_id, state) VALUES (?1, ?2, ?3, 'check', ?4, 'ok')`)
50 .run(crypto.randomUUID(), repo.value.id, issue.value.id, a.value.id);
51 }
52 const made = await writeBoard(app, getUserByName(app.db, "alice"), "fixture", {
53 title: "Fixture", range: "all", refresh: "off",
54 panels: [panel("Runs"), panel("<script>alert(1)</script>")],
55 });
56 if (!made.ok) throw new Error(`board ${JSON.stringify(made.error)}`);
57 const many = await writeBoard(app, getUserByName(app.db, "alice"), "many", {
58 title: "Many", range: "all", refresh: "off", panels: Array.from({ length: 11 }, (_, i) => panel(`P${i}`)),
59 });
60 if (!many.ok) throw new Error("many");
61 }, 30_000);
62
63 afterAll(async () => {
64 servers.stop();
65 app.close();
66 await rm(dataDir, { recursive: true, force: true });
67 });
68
69 test("the boards tool draws the board card", () => {
70 expect(CARD_OF_TOOL["boards"]).toBe("board");
71 });
72
73 test("its numbers are the page's, private repositories' rows included as the page includes them", async () => {
74 const result = await call({ slug: "fixture" });
75 const card = result.structuredContent.card;
76 expect(card.kind).toBe("board");
77 expect(card.title).toBe("Fixture");
78 const shown = await showBoard(app, getUserByName(app.db, "alice"), "fixture");
79 if (!shown.ok) throw new Error("show");
80 shown.value.panels.forEach((p, i) => {
81 const page = drawPanel(app, shown.value, p, { slug: "fixture", index: i }).text;
82 expect(page).toContain(`>${card.rows[i].value}</text>`);
83 });
84 // Two runs, one in a private repository: the page counts both, so does the card.
85 expect(card.rows[0]).toEqual({ label: "Runs", value: "2" });
86 });
87
88 test("a panel title with <script> stays text in the model and escaped on the page", async () => {
89 const card = (await call({ slug: "fixture" })).structuredContent.card;
90 expect(card.rows[1].label).toBe("<script>alert(1)</script>");
91 const shown = await showBoard(app, getUserByName(app.db, "alice"), "fixture");
92 if (!shown.ok) throw new Error("show");
93 expect(drawPanel(app, shown.value, shown.value.panels[1]!, { slug: "fixture", index: 1 }).text).not.toContain("<script>alert");
94 });
95
96 test("at most 8 panels, then how many more", async () => {
97 const card = (await call({ slug: "many" })).structuredContent.card;
98 expect(card.rows).toHaveLength(9);
99 expect(card.rows[8]).toEqual({ label: "More", value: "3 more" });
100 });
101
102 test("the list and a panel's data have no card", async () => {
103 expect((await call({})).structuredContent).toBeUndefined();
104 expect((await call({ slug: "fixture", panel: 0 })).structuredContent).toBeUndefined();
105 expect(cardFor("boards", { boards: [] })).toBeUndefined();
106 });
107 });
test/mcp.test.ts +2
@@ -1287,6 +1287,7 @@ describe("the forge over MCP", () => {
12871287 expect(uris).toEqual([
12881288 "ui://forge/app",
12891289 "ui://forge/asked",
1290 "ui://forge/board",
12901291 "ui://forge/crew",
12911292 "ui://forge/decision",
12921293 "ui://forge/explore",
@@ -1314,6 +1315,7 @@ describe("the forge over MCP", () => {
13141315 expect(meta("asks")).toBe("ui://forge/decision");
13151316 expect(meta("milestones")).toBe("ui://forge/wave");
13161317 expect(meta("map")).toBe("ui://forge/map");
1318 expect(meta("boards")).toBe("ui://forge/board");
13171319 expect(meta("feed")).toBe("ui://forge/feed");
13181320 expect(meta("issue")).toBe("ui://forge/issue");
13191321 expect(meta("explore")).toBe("ui://forge/explore");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs