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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10996-the-mcp-boards-tool-answers-with-a-ui-fo → main 3 commits fork
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`; | ||
| 75 | 75 | | `board_delete` | — | Delete a board by slug (history keeps it) | 194 | |
| 76 | 76 | | `board_pins` | — | The panels pinned to the caller's home (person admins) | 302 | |
| 77 | 77 | | `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 | | |
| 79 | 79 | | `branches` | `create`, `delete`, `branches`, `tags` | A repo's branches; `name`, `action` create (from `from`) or delete one, not the default | 407 | |
| 80 | 80 | | `builds` | — | A repo's build pins (`refs/builds/`): label, commit, pinned by, note | 231 | |
| 81 | 81 | | `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 @@ | ||
| 1 | 1 | import { listAllBoards, showBoard } from "../../../actions/boards.ts"; |
| 2 | 2 | import { panelData, parseWindow } from "../../../boards/panel_data.ts"; |
| 3 | import { panelNow } from "../../../boards/panel_now.ts"; | |
| 3 | 4 | import { good, bad, type Tool, str, boardRefused, mayBoards, boardFailed } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | 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`", | |
| 8 | 9 | description: |
| 9 | 10 | "The forge's boards for person admins: slug, title, range, refresh and panels.", |
| 10 | 11 | inputSchema: { |
| @@ -28,7 +29,10 @@ export const tool: Tool = { | ||
| 28 | 29 | } |
| 29 | 30 | const shown = await showBoard(ctx.app, user, str(args["slug"])); |
| 30 | 31 | 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 | } | |
| 32 | 36 | const index = args["panel"]; |
| 33 | 37 | const panel = typeof index === "number" && Number.isInteger(index) ? shown.value.panels[index] : undefined; |
| 34 | 38 | 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"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 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"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -40,7 +40,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 40 | 40 | |
| 41 | 41 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 42 | 42 | 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"]; | |
| 44 | 44 | |
| 45 | 45 | /** The tool whose answer each card draws. */ |
| 46 | 46 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -60,6 +60,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 60 | 60 | issue: "issue", |
| 61 | 61 | flow: "flow", |
| 62 | 62 | scorecard: "crew", |
| 63 | boards: "board", | |
| 63 | 64 | }; |
| 64 | 65 | |
| 65 | 66 | const CARD_TITLE: Readonly<Record<CardKind, string>> = { |
| @@ -76,6 +77,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 76 | 77 | issue: "Issue", |
| 77 | 78 | flow: "Flow", |
| 78 | 79 | crew: "Crew", |
| 80 | board: "Board", | |
| 79 | 81 | }; |
| 80 | 82 | |
| 81 | 83 | type Obj = Record<string, unknown>; |
| @@ -89,6 +91,8 @@ const FEED_LIMIT = 10; | ||
| 89 | 91 | const PULLS_LIMIT = 20; |
| 90 | 92 | const ASKED_WORDS = 80; |
| 91 | 93 | const ASKED_LIMIT = 20; |
| 94 | /** A board card shows this many panels, then counts the rest. */ | |
| 95 | const BOARD_PANELS = 8; | |
| 92 | 96 | /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */ |
| 93 | 97 | const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/; |
| 94 | 98 | |
| @@ -158,6 +162,18 @@ export function cardFor( | ||
| 158 | 162 | } |
| 159 | 163 | return { kind, title: CARD_TITLE[kind], status: verdict(typed, now), rows, actions }; |
| 160 | 164 | } |
| 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 | } | |
| 161 | 177 | if (kind === "crew") { |
| 162 | 178 | // `scorecard` answers `{ agents: [{ username, all, recent }], keeperDays: [{ landed }] }`. |
| 163 | 179 | 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", () => { | ||
| 1287 | 1287 | expect(uris).toEqual([ |
| 1288 | 1288 | "ui://forge/app", |
| 1289 | 1289 | "ui://forge/asked", |
| 1290 | "ui://forge/board", | |
| 1290 | 1291 | "ui://forge/crew", |
| 1291 | 1292 | "ui://forge/decision", |
| 1292 | 1293 | "ui://forge/explore", |
| @@ -1314,6 +1315,7 @@ describe("the forge over MCP", () => { | ||
| 1314 | 1315 | expect(meta("asks")).toBe("ui://forge/decision"); |
| 1315 | 1316 | expect(meta("milestones")).toBe("ui://forge/wave"); |
| 1316 | 1317 | expect(meta("map")).toBe("ui://forge/map"); |
| 1318 | expect(meta("boards")).toBe("ui://forge/board"); | |
| 1317 | 1319 | expect(meta("feed")).toBe("ui://forge/feed"); |
| 1318 | 1320 | expect(meta("issue")).toBe("ui://forge/issue"); |
| 1319 | 1321 | expect(meta("explore")).toBe("ui://forge/explore"); |