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

MCP `archive` draws the app card, so archiving or waking an app from a harness shows what was put away and how to bring it back #11453

Merged opened this

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

Merged

mason/forge:mason/11449-mcp-archive-draws-the-app-card-so-archiv → main 4 commits fork 4 threads · 4 unresolved

  • Merged. mason/11449-mcp-archive-draws-the-app-card-so-archiv was merged into main as 51f03d1.

Review

Sign in to review this change.
5 files changed +144 −3
docs/MCP.md +1 −1
@@ -55,7 +55,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
5555 | `app_members` | — | People a private app names, who may enter it and its previews | 297 |
5656 | `app_notes` | `hide`, `ban`, `unban` | Notes from an app's forge button and banned senders; hide a note, ban or unban its sender | 306 |
5757 | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 343 |
58 | `archive` | — | Archive or wake a repository (`archived`) | 332 |
58 | `archive` | — | Archive or wake a repository (`archived`) | 380 |
5959 | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 |
6060 | `asks` | — | What waits on you, most important first; saved filter applies | 453 |
6161 | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 535 |
src/http/mcp/tools/archive.ts +6 −1
@@ -1,4 +1,5 @@
11 import { scopeAllows } from "../../../core/tokens.ts";
2 import { getRepo } from "../../../core/repos.ts";
23 import { setArchived } from "../../../actions/repos.ts";
34 import { good, bad, type Tool, REPO_ARG, repoArg, MAX_ARCHIVE_REPOS, archiveMany, isAnswer } from "../shared.ts";
45
@@ -28,7 +29,7 @@ export const tool: Tool = {
2829 const repo = repoArg(ctx, user, args);
2930 if (isAnswer(repo)) return repo;
3031 const archived = args["archived"];
31 if (archived === undefined) return good({ archived: repo.archivedAt !== null });
32 if (archived === undefined) return good({ repo: `${repo.ownerName}/${repo.name}`, archived: repo.archivedAt !== null, since: repo.archivedAt });
3233 if (typeof archived !== "boolean")
3334 return bad("invalid", "`archived` is true or false");
3435 const dryRun = args["dryRun"] !== false;
@@ -37,8 +38,12 @@ export const tool: Tool = {
3738 const set = await setArchived(ctx.app, user, repo, archived, dryRun);
3839 if (!set.ok)
3940 return bad("forbidden", "only an admin of this repository archives or wakes it");
41 // The stored date, read back, so the card and a later read agree.
42 const stored = dryRun ? repo : (getRepo(ctx.app.db, repo.ownerName, repo.name) ?? repo);
4043 return good({
outdated

since falls back to new Date().toISOString() when archiving, which is a time the tool invented, not the stored archivedAt; the card then shows a date the repository does not carry. Re-read the repository after setArchived and use its real archivedAt (null for a dry run), so the card and a later read agree.

44 repo: `${repo.ownerName}/${repo.name}`,
4145 ...set.value,
46 since: set.value.archived ? stored.archivedAt : null,
4247 says: dryRun
4348 ? `This is the plan; nothing changed. It is ${set.value.was ? "archived" : "awake"} now; send dryRun: false to ${set.value.archived ? "archive" : "wake"} it.`
4449 : set.value.archived
src/web/studio/cards.ts +44
@@ -60,6 +60,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
6060 feed: "feed",
6161 explore: "explore",
6262 new_app: "app",
63 archive: "app",
6364 pull: "pull",
6465 pulls: "pulls",
6566 asked: "asked",
@@ -316,6 +317,49 @@ export function cardFor(
316317 actions.push({ label: `Tell ${repo}`, tool: "tell", args: { repo, context: {} }, words: true });
317318 return { kind, title: "New app", status, rows, actions };
318319 }
320 if (tool === "archive") {
321 const v = obj(value);
322 const dry = v["dryRun"] === true;
323 const target = v["archived"] === true;
324 if (Array.isArray(v["rows"])) {
325 // A bulk answer: one card, one row per repository.
326 const found = v["rows"].map(obj);
327 for (const r of found.slice(0, LIMIT))
328 rows.push({
329 label: text(r["repo"]),
330 value: [text(r["outcome"]), text(r["reason"])].filter((x) => x !== "").join(" · "),
331 ...(web(origin) && REPO_SLUG.test(text(r["repo"])) ? { link: `${origin}/${text(r["repo"])}` } : {}),
332 });
333 if (found.length > LIMIT) rows.push({ label: "More", value: `${found.length - LIMIT} more not shown` });
334 const verb = target ? "archive" : "wake";
335 status = dry ? `plan: would ${verb} ${found.length} · nothing changed` : `${target ? "archived" : "woken"} ${found.length}`;
336 const first = found.map((r) => text(r["repo"])).find((n) => REPO_SLUG.test(n));
337 const names = found
338 .filter((r) => text(r["outcome"]) !== "refused")
outdated

The apply-all action sends every name that looks like a slug, including repositories the plan refused (e.g. unknown alice/none). Build names only from rows whose outcome is not refused, so the button applies what the plan said would happen.

339 .map((r) => text(r["repo"]))
340 .filter((n) => REPO_SLUG.test(n));
341 if (dry && names.length > 0)
342 actions.push({ label: `${target ? "Archive" : "Wake"} ${names.length}`, tool: "archive", args: { repos: names, archived: target, dryRun: false } });
343 else if (!dry && first !== undefined && text(v["operation"]) !== "")
344 actions.push({ label: "Undo", tool: "limb", args: { action: "undo", operation: text(v["operation"]), app: first } });
345 return { kind, title: "Archive", status, rows, actions };
346 }
347 const repo = text(v["repo"]);
348 if (repo === "" || typeof v["archived"] !== "boolean") return undefined;
349 const since = text(v["since"]);
350 const now = v["archived"] === true;
351 status = dry && v["was"] !== undefined
352 ? `plan: would ${target ? "archive" : "wake"} · nothing changed`
353 : now ? "archived" : "awake";
outdated

Principle 1: the "Now" row repeats the status line (archived since … / awake), and for a plan it only restates what says also says. Nothing explains itself twice: drop this row, keep the status.

354 rows.push({ label: "App", value: repo, ...(web(origin) && REPO_SLUG.test(repo) ? { link: `${origin}/${repo}` } : {}) });
outdated

Principle 1: the "Says" row is a third copy of the status and the plan sentence ("It is awake now; send dryRun: false to archive it" is a tool instruction, not words for a person, and the button already does it). Drop it; the rows should be App and, when archived, the date.

355 if (since !== "" && now && !(dry && v["was"] !== undefined)) rows.push({ label: "Archived", value: timeAgo(since) });
356 if (REPO_SLUG.test(repo)) {
357 if (dry && v["was"] !== undefined)
358 actions.push({ label: target ? "Archive it" : "Wake it", tool: "archive", args: { repo, archived: target, dryRun: false } });
359 else if (now) actions.push({ label: "Wake it", tool: "archive", args: { repo, archived: false, dryRun: false } });
360 }
361 return { kind, title: "Archive", status, rows, actions };
362 }
319363 if (kind === "pull") {
320364 const p = obj(value);
321365 const state = text(p["state"]);
test/archive-mcp.test.ts +1 −1
@@ -86,7 +86,7 @@ afterAll(async () => {
8686 });
8787
8888 test("without `archived` it says how the repository is; with it, it plans first", async () => {
89 expect((await mcp(tok["mallory"]!, { repo: "acme/svc" })).value).toEqual({ archived: false });
89 expect((await mcp(tok["mallory"]!, { repo: "acme/svc" })).value).toEqual({ archived: false, repo: "acme/svc", since: null });
9090 const planned = await mcp(tok["founder"]!, { repo: "acme/svc", archived: true });
9191 expect(planned.value).toMatchObject({ dryRun: true, archived: true, was: false });
9292 expect(archived()).toBe(false);
test/mcp-archive-card.test.ts added +92
@@ -0,0 +1,92 @@
1 /** MCP `archive` draws the `ui://forge/app` card: what was put away, and the one action that wakes it. */
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 { getRepo } from "../src/core/repos.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 { toolEntries } from "../src/http/mcp.ts";
13 import { TOOLS } from "../src/http/mcp/tools.ts";
14 import { CARD_OF_TOOL, CARD_URI } from "../src/web/studio/cards.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 describe("MCP archive card", () => {
20 let dataDir = "";
21 let app: App;
22 let servers: Servers;
23 let token = "";
24
25 const call = async (args: unknown) => {
26 const res = await fetch(`${servers.url}/mcp`, {
27 method: "POST",
28 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
29 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "archive", arguments: args } }),
30 });
31 return ((await res.json()) as any).result;
32 };
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-archive-card-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
39 if (!r.ok) throw new Error("user");
40 token = createToken(app.db, r.value.id, "admin", "admin").token;
41 for (const name of ["one", "two"]) {
42 const repo = await createRepo(app, r.value, { name, visibility: "public" });
43 if (!repo.ok) throw new Error("repo");
44 }
45 }, 60_000);
46
47 afterAll(async () => {
48 servers.stop();
49 app.close();
50 await rm(dataDir, { recursive: true, force: true });
51 });
52
53 test("the tool is listed under the app card's resource", () => {
54 expect(CARD_OF_TOOL["archive"]).toBe("app");
55 const entry = toolEntries(TOOLS).find((t: any) => t.name === "archive") as any;
56 expect(entry._meta.ui.resourceUri).toBe(CARD_URI("app"));
57 });
58
59 test("a single repository: plan, archive, then wake", async () => {
60 const plan = (await call({ repo: "alice/one", archived: true })).structuredContent.card;
61 expect(plan.kind).toBe("app");
62 expect(plan.status).toContain("would archive");
63 expect(plan.actions[0].args).toEqual({ repo: "alice/one", archived: true, dryRun: false });
64
65 const done = (await call({ repo: "alice/one", archived: true, dryRun: false })).structuredContent.card;
66 expect(done.status).toBe("archived");
67 expect(done.rows.map((r: any) => r.label)).toEqual(["App", "Archived"]);
68 expect(getRepo(app.db, "alice", "one")!.archivedAt).not.toBeNull();
69 expect(done.actions).toEqual([{ label: "Wake it", tool: "archive", args: { repo: "alice/one", archived: false, dryRun: false } }]);
70
71 const asked = (await call({ repo: "alice/one" })).structuredContent.card;
72 expect(asked.status).toBe("archived");
73 expect(asked.actions[0].label).toBe("Wake it");
74
75 const woke = (await call({ repo: "alice/one", archived: false, dryRun: false })).structuredContent.card;
76 expect(woke.status).toBe("awake");
77 expect(woke.actions).toEqual([]);
78 });
79
80 test("repos: one card, one row per repository", async () => {
81 const plan = (await call({ repos: ["alice/one", "alice/two", "alice/none"] })).structuredContent.card;
82 expect(plan.kind).toBe("app");
83 expect(plan.rows.map((r: any) => r.label)).toEqual(["alice/one", "alice/two", "alice/none"]);
84 expect(plan.status).toContain("nothing changed");
85 // The button applies what the plan said would happen, not the refused name.
86 expect(plan.actions[0].args.repos).toEqual(["alice/one", "alice/two"]);
87 const done = (await call({ repos: ["alice/one", "alice/two"], dryRun: false })).structuredContent.card;
88 expect(done.rows.length).toBe(2);
89 expect(done.actions[0].tool).toBe("limb");
90 expect(done.actions[0].args.action).toBe("undo");
91 });
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs