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

Merged opened this

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

Merged

scout/forge:scout/7376-mcp-asked-draws-a-card-in-mcp-apps-hosts → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7376-mcp-asked-draws-a-card-in-mcp-apps-hosts was merged into main as d53fbe0.

Review

Sign in to review this change.
5 files changed +131 −6
docs/MCP.md +3 −3
@@ -44,7 +44,7 @@ for a tunnel and a forge that sleeps.
4444 | `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 |
4545 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
4646 | `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 | |
4848 | `asks` | `{ …chips }`: what waits on you, most important first; your saved filter applies unless a chip argument overrides it | |
4949 | `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 |
5050 | `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
358358
359359 ## Cards, the contract and the plugins (#1116)
360360
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
362362 `_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
364364 its ready, working and waiting counts, and the first five ready issues linked
365365 to their pages; it has no actions. The decision card's rows link to the pull or issue
366366 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";
55
66 export const tool: Tool = {
77 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",
99 description:
1010 "What people asked the crew for: unmerged by default, oldest first.",
1111 inputSchema: {
src/web/studio/cards.ts +32 −2
@@ -10,7 +10,7 @@
1010 import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212
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";
1414
1515 export interface CardRow {
1616 readonly label: string;
@@ -39,7 +39,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
3939
4040 export const CARD_MIME = "text/html;profile=mcp-app";
4141 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"];
4343
4444 /** The tool whose answer each card draws. */
4545 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
@@ -55,6 +55,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
5555 new_app: "app",
5656 pull: "pull",
5757 pulls: "pulls",
58 asked: "asked",
5859 issue: "issue",
5960 };
6061
@@ -68,6 +69,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = {
6869 explore: "Explore",
6970 pull: "Pull",
7071 pulls: "Pulls",
72 asked: "Asked",
7173 issue: "Issue",
7274 };
7375
@@ -80,6 +82,8 @@ const text = (v: unknown): string =>
8082 const LIMIT = 12;
8183 const FEED_LIMIT = 10;
8284 const PULLS_LIMIT = 20;
85 const ASKED_WORDS = 80;
86 const ASKED_LIMIT = 20;
8387 /** The forge's id shape (src/kernel/id.ts): 26 lowercase Crockford base32 characters. */
8488 const RUN_ID = /^[0-9a-hjkmnp-tv-z]{26}$/;
8589
@@ -192,6 +196,32 @@ export function cardFor(
192196 }
193197 return { kind, title: CARD_TITLE[kind], status, rows, actions };
194198 }
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 }
195225 if (kind === "issue") {
196226 const v = obj(value);
197227 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" });

Comment contradicts the assertion below (the stale, older ask comes first). Reword.

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", () => {
12141214 const uris = list.body.result.resources.map((r: any) => r.uri).sort();
12151215 expect(uris).toEqual([
12161216 "ui://forge/app",
1217 "ui://forge/asked",
12171218 "ui://forge/decision",
12181219 "ui://forge/explore",
12191220 "ui://forge/feed",
@@ -1244,6 +1245,7 @@ describe("the forge over MCP", () => {
12441245 expect(meta("explore")).toBe("ui://forge/explore");
12451246 expect(meta("pull")).toBe("ui://forge/pull");
12461247 expect(meta("pulls")).toBe("ui://forge/pulls");
1248 expect(meta("asked")).toBe("ui://forge/asked");
12471249 });
12481250
12491251 test("tools/call pull carries the card beside an unchanged text answer", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs