Public: anyone can read this; the crew can change it.
MCP `asked` draws a card in MCP Apps hosts: what you asked the crew, its state and the pull that closes it #7457
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7376-mcp-asked-draws-a-card-in-mcp-apps-hosts → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +131
−6
docs/MCP.md +3 −3
| @@ -44,7 +44,7 @@ for a tunnel and a forge that sleeps. | ||
| 44 | 44 | | `app_notes` | `{ app, action?, note? }`: the notes sent from an app's forge button and its banned senders; `hide` a note, `ban` or `unban` its sender; an admin of the repository | yes | |
| 45 | 45 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 46 | 46 | | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes | |
| 47 | | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | | | |
| 47 | | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write; draws the `ui://forge/asked` card: flagged asks first, then each ask's words, its state and a link to the merged or open pull that closes it, else the issue; at most 20 rows | | | |
| 48 | 48 | | `asks` | `{ …chips }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it | | |
| 49 | 49 | | `asks_act` | `{ action, items, reason?, dryRun? }`: decide asks in bulk (up to 100): `dismiss`, `decline`, `mute`, `ship`, `close` (with `reason`) or `replan`; planned first (`dryRun` is true unless `false`), as `POST /api/v1/asks/bulk` | yes | |
| 50 | 50 | | `asks_decide` | `{ item, give_back? }`: let the decider answer a parked hand-over (`parked:<id>`); with `give_back`, give a hand-over back to the crew instead (`handover:<id>`, or `assigned:<id>` for a pull a pass gave up on and assigned to you, whose ask carries the pass's reason): it is assigned to the builder that last worked on it and a comment says so; the same as the card's **Give back to the crew** and `POST /api/v1/asks/give-back` | yes | |
| @@ -358,9 +358,9 @@ answers | ||
| 358 | 358 | |
| 359 | 359 | ## Cards, the contract and the plugins (#1116) |
| 360 | 360 | |
| 361 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `waves` (one wave), `map`, `feed` and `pull` name a `ui://` resource in | |
| 361 | - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` and `asked` name a `ui://` resource in | |
| 362 | 362 | `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`, |
| 363 | `decision`, `wave`, `map`, `feed`, `pull`. The Plan card (`map`) shows one row per pillar with | |
| 363 | `decision`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with | |
| 364 | 364 | its ready, working and waiting counts, and the first five ready issues linked |
| 365 | 365 | to their pages; it has no actions. The decision card's rows link to the pull or issue |
| 366 | 366 | they are about (a paused repository's row has none). |
src/http/mcp/tools/asked.ts +1 −1
| @@ -5,7 +5,7 @@ import { ORDER_WORDS } from "../../../core/arg_words.ts"; | ||
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "asked", |
| 8 | doc: "`{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write", | |
| 8 | doc: "`{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, the outcome (landed in a pull, who is building, parked and why, failed) and its sentence, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write; draws the `ui://forge/asked` card: flagged asks first, then each ask's words, its state and a link to the merged or open pull that closes it, else the issue; at most 20 rows", | |
| 9 | 9 | description: |
| 10 | 10 | "What people asked the crew for: unmerged by default, oldest first.", |
| 11 | 11 | inputSchema: { |
src/web/studio/cards.ts +32 −2
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import { html, raw } from "../html.ts"; |
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "explore" | "issue"; | |
| 13 | export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue"; | |
| 14 | 14 | |
| 15 | 15 | export interface CardRow { |
| 16 | 16 | readonly label: string; |
| @@ -39,7 +39,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 39 | 39 | |
| 40 | 40 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 41 | 41 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 42 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "explore", "issue"]; | |
| 42 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue"]; | |
| 43 | 43 | |
| 44 | 44 | /** The tool whose answer each card draws. */ |
| 45 | 45 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -55,6 +55,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { | ||
| 55 | 55 | new_app: "app", |
| 56 | 56 | pull: "pull", |
| 57 | 57 | pulls: "pulls", |
| 58 | asked: "asked", | |
| 58 | 59 | issue: "issue", |
| 59 | 60 | }; |
| 60 | 61 | |
| @@ -68,6 +69,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 68 | 69 | explore: "Explore", |
| 69 | 70 | pull: "Pull", |
| 70 | 71 | pulls: "Pulls", |
| 72 | asked: "Asked", | |
| 71 | 73 | issue: "Issue", |
| 72 | 74 | }; |
| 73 | 75 | |
| @@ -80,6 +82,8 @@ const text = (v: unknown): string => | ||
| 80 | 82 | const LIMIT = 12; |
| 81 | 83 | const FEED_LIMIT = 10; |
| 82 | 84 | const PULLS_LIMIT = 20; |
| 85 | const ASKED_WORDS = 80; | |
| 86 | const ASKED_LIMIT = 20; | |
| 83 | 87 | /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */ |
| 84 | 88 | const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/; |
| 85 | 89 | |
| @@ -192,6 +196,32 @@ export function cardFor( | ||
| 192 | 196 | } |
| 193 | 197 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
| 194 | 198 | } |
| 199 | if (kind === "asked") { | |
| 200 | // `asked` answers `{ asked: [{ repo, number, title, state, flagged, pulls }] }`. | |
| 201 | const asks = list(obj(value)["asked"]).map(obj); | |
| 202 | const flagged = asks.filter((a) => a["flagged"] === true); | |
| 203 | const waiting = asks.filter((a) => a["state"] === "waiting").length; | |
| 204 | status = `${waiting} waiting · ${flagged.length} flagged`; | |
| 205 | for (const a of [...flagged, ...asks.filter((a) => a["flagged"] !== true)].slice(0, ASKED_LIMIT)) { | |
| 206 | const repo = text(a["repo"]); | |
| 207 | const number = text(a["number"]); | |
| 208 | // The pull that closes it: a landed one, else an open one, else the issue. | |
| 209 | const pulls = list(a["pulls"]).map(obj); | |
| 210 | const closing = pulls.find((p) => p["state"] === "merged") ?? pulls.find((p) => p["state"] === "open"); | |
| 211 | const pullNumber = closing === undefined ? "" : text(closing["number"]); | |
| 212 | const path = | |
| 213 | /^[1-9]\d*$/.test(pullNumber) ? `/${repo}/pulls/${pullNumber}` : `/${repo}/issues/${number}`; | |
| 214 | const title = text(a["title"]); | |
| 215 | rows.push({ | |
| 216 | label: title.length > ASKED_WORDS ? `${title.slice(0, ASKED_WORDS - 1)}…` : title, | |
| 217 | value: text(a["state"]), | |
| 218 | ...(web(origin) && slugOf(repo) && /^[1-9]\d*$/.test(number) && here(origin, path) | |
| 219 | ? { link: origin + path } | |
| 220 | : {}), | |
| 221 | }); | |
| 222 | } | |
| 223 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; | |
| 224 | } | |
| 195 | 225 | if (kind === "issue") { |
| 196 | 226 | const v = obj(value); |
| 197 | 227 | const number = text(v["number"]); |
test/mcp-asked-card.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** MCP `asked` draws the `ui://forge/asked` card: flagged first, a state per ask, linked to the closing pull or the issue. */ | |
| 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 { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { cardFor } from "../src/web/studio/cards.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const DAY = 24 * 60 * 60 * 1000; | |
| 18 | ||
| 19 | describe("MCP asked card", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let token = ""; | |
| 24 | const numbers: Record<string, number> = {}; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-asked-card-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 31 | username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 32 | if (!a.ok) throw new Error("users"); | |
| 33 | token = createToken(app.db, a.value.id, "ro", "ro").token; | |
| 34 | const made = await createRepo(app, a.value, { name: "game", visibility: "private" }); | |
| 35 | if (!made.ok) throw new Error(String(made.error)); | |
| 36 | // The older ask is stale with nothing built, so it is flagged and oldest-first leads with it. | |
| 37 | for (const [title, days] of [["stale and unbuilt", 9], ["fresh", 1]] as const) { | |
| 38 | const i = createIssue(app.db, { repoId: made.value.id, authorId: a.value.id, title }); | |
| 39 | if (!i.ok) throw new Error(i.error); | |
| 40 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2") | |
| 41 | .run(new Date(Date.now() - days * DAY).toISOString(), i.value.id); | |
| 42 | numbers[title] = i.value.number; | |
| 43 | } | |
| 44 | }, 30_000); | |
| 45 | ||
| 46 | afterAll(async () => { | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | await rm(dataDir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("tools/call asked carries the card, flagged first, beside an unchanged text answer", async () => { | |
| 53 | const res = await fetch(`${servers.url}/mcp`, { | |
| 54 | method: "POST", | |
| 55 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 56 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asked", arguments: {} } }), | |
| 57 | }); | |
| 58 | const body: any = await res.json(); | |
| 59 | const text = JSON.parse(body.result.content[0].text); | |
| 60 | expect(text.asked.map((x: any) => x.title)).toEqual(["stale and unbuilt", "fresh"]); | |
| 61 | expect(text.asked[0].flagged).toBe(true); | |
| 62 | const card = body.result.structuredContent.card; | |
| 63 | expect(card.kind).toBe("asked"); | |
| 64 | expect(card.status).toBe("2 waiting · 1 flagged"); | |
| 65 | // Put "fresh" first in the answer's order: the card still leads with the flagged ask. | |
| 66 | const flipped = cardFor("asked", { asked: [...text.asked].reverse() }, servers.url)!; | |
| 67 | expect(flipped.rows.map((r) => r.label)).toEqual(["stale and unbuilt", "fresh"]); | |
| 68 | expect(card.rows).toEqual([ | |
| 69 | { label: "stale and unbuilt", value: "waiting", link: `${servers.url}/alice/game/issues/${numbers["stale and unbuilt"]}` }, | |
| 70 | { label: "fresh", value: "waiting", link: `${servers.url}/alice/game/issues/${numbers["fresh"]}` }, | |
| 71 | ]); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the closing pull, the 80-character cut, the 20-row ceiling, links only on this forge", () => { | |
| 75 | const ask = (over: Record<string, unknown>) => ({ repo: "alice/game", number: 1, title: "t", state: "waiting", flagged: false, pulls: [], ...over }); | |
| 76 | const card = cardFor("asked", { asked: [ | |
| 77 | ask({ number: 1, title: "x".repeat(200), state: "shipped", pulls: [{ number: 7, state: "closed" }, { number: 9, state: "merged" }] }), | |
| 78 | ask({ number: 2, state: "building", pulls: [{ number: 8, state: "open" }] }), | |
| 79 | ask({ number: 3, state: "closed" }), | |
| 80 | ] }, "https://forge.test")!; | |
| 81 | expect(card.status).toBe("0 waiting · 0 flagged"); | |
| 82 | expect(card.rows[0]!.label.length).toBe(80); | |
| 83 | expect(card.rows.map((r) => r.link)).toEqual([ | |
| 84 | "https://forge.test/alice/game/pulls/9", | |
| 85 | "https://forge.test/alice/game/pulls/8", | |
| 86 | "https://forge.test/alice/game/issues/3", | |
| 87 | ]); | |
| 88 | const many = Array.from({ length: 30 }, (_, i) => ask({ number: i + 1 })); | |
| 89 | expect(cardFor("asked", { asked: many }, "https://forge.test")!.rows.length).toBe(20); | |
| 90 | expect(cardFor("asked", { asked: many }, "")!.rows[0]!.link).toBeUndefined(); | |
| 91 | expect(cardFor("asked", { asked: [ask({ repo: "/evil.example/x" })] }, "https://forge.test")!.rows[0]!.link).toBeUndefined(); | |
| 92 | }); | |
| 93 | }); |
test/mcp.test.ts +2
| @@ -1214,6 +1214,7 @@ describe("the forge over MCP", () => { | ||
| 1214 | 1214 | const uris = list.body.result.resources.map((r: any) => r.uri).sort(); |
| 1215 | 1215 | expect(uris).toEqual([ |
| 1216 | 1216 | "ui://forge/app", |
| 1217 | "ui://forge/asked", | |
| 1217 | 1218 | "ui://forge/decision", |
| 1218 | 1219 | "ui://forge/explore", |
| 1219 | 1220 | "ui://forge/feed", |
| @@ -1244,6 +1245,7 @@ describe("the forge over MCP", () => { | ||
| 1244 | 1245 | expect(meta("explore")).toBe("ui://forge/explore"); |
| 1245 | 1246 | expect(meta("pull")).toBe("ui://forge/pull"); |
| 1246 | 1247 | expect(meta("pulls")).toBe("ui://forge/pulls"); |
| 1248 | expect(meta("asked")).toBe("ui://forge/asked"); | |
| 1247 | 1249 | }); |
| 1248 | 1250 | |
| 1249 | 1251 | test("tools/call pull carries the card beside an unchanged text answer", async () => { |
new above
offline
Comment contradicts the assertion below (the stale, older ask comes first). Reword.