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

MCP `app_notes`: an app's admin lists the notes sent from its forge button, hides one, bans or unbans its sender #7273

Merged opened this

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

Merged

torque/forge:torque/7214-mcp-app-notes-an-app-s-admin-lists-the-n → main 1 commit fork

  • Merged. torque/7214-mcp-app-notes-an-app-s-admin-lists-the-n was merged into main as 4afab62.

Review

Sign in to review this change.
5 files changed +225 −0
docs/MCP.md +1
@@ -40,6 +40,7 @@ for a tunnel and a forge that sleeps.
4040 | `app_domains` | `{ repo, action, host? }`: `list`, `add`, `check` (looks the DNS proof up now) or `remove` an app's own domains, with the records to publish, as `…/app/domains` answers; an admin of the repository with an admin-scope token | yes |
4141 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
4242 | `app_members` | `{ repo, members?, dryRun? }`: the people a private app names, who may enter it and its previews and nothing else; a writer (never an agent) changes the list with an admin-scope token | yes |
43 | `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 |
4344 | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | |
4445 | `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 |
4546 | `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 | |
src/http/mcp/tools/app_notes.ts added +51
@@ -0,0 +1,51 @@
1 import { moderateNote, ownerNotes, unbanSender } from "../../../actions/app_notes.ts";
2 import { getAppByRepo } from "../../../core/apps.ts";
3 import { good, bad, type Answer, type Tool, APP_ARG, planRepo, isAnswer, str } from "../shared.ts";
4
5 const ACTIONS = ["hide", "ban", "unban"] as const;
6 type Action = (typeof ACTIONS)[number];
7
8 /** The same gate and the same moderation as the studio's card and the terminal's notes screen. */
9 const refused = (error: "forbidden" | "not_found" | "unavailable"): Answer =>
10 error === "forbidden"
11 ? bad("forbidden", "an app's notes are for an admin of its repository")
12 : error === "not_found"
13 ? bad("not_found", "no such note in this app")
14 : bad("unavailable", "the note's issue could not be closed just now");
15
16 export const tool: Tool = {
17 name: "app_notes",
18 doc: "`{ 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",
19 description: "Notes from an app's forge button and banned senders; hide a note, ban or unban its sender. Admin.",
20 inputSchema: {
21 type: "object",
22 properties: {
23 app: APP_ARG,
24 action: { type: "string", enum: [...ACTIONS] },
25 note: { type: "string" },
26 },
27 required: ["app"],
28 },
29 writes: true,
30 readsWithout: "action",
31 async run(ctx, user, args) {
32 const repo = planRepo(ctx, user, args);
33 if (isAnswer(repo)) return repo;
34 const record = getAppByRepo(ctx.app.db, repo.id);
35 if (record === undefined) return bad("not_found", "no such app or repository (owner/name)");
36 const action = args["action"];
37 if (action === undefined) {
38 const seen = ownerNotes(ctx.app, record, repo, user);
39 return seen === undefined ? refused("forbidden") : good(seen);
40 }
41 if (typeof action !== "string" || !(ACTIONS as readonly string[]).includes(action))
42 return bad("invalid", "`action` is hide, ban or unban");
43 const note = str(args["note"]);
44 if (note === "" || note.length > 64) return bad("invalid", "`note` is a note's id, from the list");
45 const done =
46 (action as Action) === "unban"
47 ? unbanSender(ctx.app, record, repo, user, note)
48 : await moderateNote(ctx.app, record, repo, user, note, action as "hide" | "ban");
49 return done.ok ? good({ note, action, says: `Done: ${action} on note ${note}.` }) : refused(done.error);
50 },
51 };
src/http/mcp/tools/index.ts +1
@@ -11,6 +11,7 @@ export { tool as app_deploy } from "./app_deploy.ts";
1111 export { tool as app_domains } from "./app_domains.ts";
1212 export { tool as app_env } from "./app_env.ts";
1313 export { tool as app_members } from "./app_members.ts";
14 export { tool as app_notes } from "./app_notes.ts";
1415 export { tool as apps } from "./apps.ts";
1516 export { tool as archive } from "./archive.ts";
1617 export { tool as asked } from "./asked.ts";
test/mcp-app-notes.test.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * The `app_notes` tool (#7214): an app's admin moderates the notes visitors
3 * send from its forge button over MCP, as the studio card and the terminal
4 * do. A note goes in through the button's endpoint (`apps/feedback.ts`); the
5 * list, hide, ban and unban come back through `/mcp`; a writer who is not an
6 * admin, and a read-only token, are refused.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { feedback } from "../src/apps/feedback.ts";
15 import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts";
16 import { getNote, isBanned } from "../src/core/app_notes.ts";
17 import { getIssue } from "../src/core/issues.ts";
18 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
19 import { canAdmin, canWrite, type Repo } from "../src/core/repos.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { promote } from "./helpers/members.ts";
25
26 quietLogs();
27
28 describe("MCP app_notes", () => {
29 let dataDir: string;
30 let app: App;
31 let servers: Servers;
32 let repo: Repo;
33 let record: AppRecord;
34 let writer: User;
35 let ownerRw: string;
36 let ownerRo: string;
37 let writerRw: string;
38 let id = 1;
39
40 const call = async (token: string, args: unknown) => {
41 const res = await fetch(`${servers.url}/mcp`, {
42 method: "POST",
43 headers: {
44 "content-type": "application/json",
45 accept: "application/json, text/event-stream",
46 authorization: `Bearer ${token}`,
47 },
48 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "app_notes", arguments: args } }),
49 });
50 const body: any = await res.json();
51 const text: string = body.result.content[0].text;
52 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
53 };
54
55 /** A visitor with no account sends a note from the button, as the panel does. */
56 const send = async (text: string, cookie = ""): Promise<{ id: string; cookie: string }> => {
57 const host = "board.apps.test";
58 const url = new URL(`http://${host}/__forge/note`);
59 const res = await feedback({
60 app,
61 forgeUrl: servers.url,
62 request: new Request(url.href, {
63 method: "POST",
64 headers: { host, origin: `http://${host}`, "content-type": "application/json", ...(cookie ? { cookie } : {}) },
65 body: JSON.stringify({ text, page: `http://${host}/cards`, viewport: "390x844" }),
66 }),
67 url,
68 record,
69 env: "production",
70 sealed: false,
71 viewerId: undefined,
72 secure: false,
73 source: `10.7.${id}.${Math.floor(Math.random() * 250)}`,
74 });
75 expect(res!.status).toBe(201);
76 const j = (await res!.json()) as { note: { id: string } };
77 return { id: j.note.id, cookie: cookie || (res!.headers.get("set-cookie") ?? "").split(";")[0]! };
78 };
79
80 beforeAll(async () => {
81 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-app-notes-"));
82 app = createApp({ dataDir, port: 0 });
83 servers = await serve(app);
84 const person = async (username: string, adminIfFirst = false) => {
85 const made = await createUser(app.db, {
86 origin: "seed",
87 trusted: true,
88 username,
89 email: `${username}@forge.local`,
90 password: "a-good-passphrase",
91 adminIfFirst,
92 });
93 if (!made.ok) throw new Error(made.error);
94 return made.value;
95 };
96 const owner = await person("olga", true);
97 writer = await person("wes");
98 promote(app.db, owner, writer);
99 const org = createOrg(app.db, "guild", owner.id);
100 if (!org.ok) throw new Error("org");
101 const writers = createTeam(app.db, org.value.id, "writers", "write");
102 if (!writers.ok) throw new Error("team");
103 addTeamMember(app.db, writers.value, writer.id);
104 const made = await createRepo(app, owner, { owner: "guild", name: "board", visibility: "public" });
105 if (!made.ok) throw new Error(String(made.error));
106 repo = made.value;
107 const rec = createAppRecord(app.db, { repoId: repo.id, slug: "board", access: "public" });
108 if (!rec.ok) throw new Error(rec.error);
109 record = rec.value;
110 ownerRw = createToken(app.db, owner.id, "rw", "rw").token;
111 ownerRo = createToken(app.db, owner.id, "ro", "ro").token;
112 writerRw = createToken(app.db, writer.id, "rw", "rw").token;
113 }, 30_000);
114
115 afterAll(async () => {
116 servers.stop();
117 app.close();
118 await rm(dataDir, { recursive: true, force: true });
119 });
120
121 test("the admin lists a note sent from the button, hides it, bans its sender and unbans them", async () => {
122 const first = await send("The card titles wrap badly on my phone");
123 const listed = await call(ownerRw, { app: "guild/board" });
124 expect(listed.value.banned).toEqual([]);
125 const shown = listed.value.notes.find((n: { id: string }) => n.id === first.id);
126 expect(shown).toMatchObject({ text: "The card titles wrap badly on my phone", person: false, banned: false });
127 expect(shown.page).toContain("/cards");
128
129 // A read-only token lists, as the studio card shows, but may not act.
130 expect((await call(ownerRo, { app: "guild/board" })).value.notes).toHaveLength(1);
131 expect((await call(ownerRo, { app: "guild/board", action: "hide", note: first.id })).error).toMatch(/^forbidden/);
132 expect(getNote(app.db, record.id, first.id)!.hidden).toBe(false);
133
134 const hidden = await call(ownerRw, { app: "guild/board", action: "hide", note: first.id });
135 expect(hidden.value).toMatchObject({ note: first.id, action: "hide" });
136 expect(getNote(app.db, record.id, first.id)!.hidden).toBe(true);
137 // Hiding closes the note's issue, as the studio's Hide does.
138 expect(getIssue(app.db, repo.id, getNote(app.db, record.id, first.id)!.issueNumber)!.state).toBe("closed");
139 expect((await call(ownerRw, { app: "guild/board" })).value.notes).toEqual([]);
140
141 const second = await send("Please add a dark theme", first.cookie);
142 await call(ownerRw, { app: "guild/board", action: "ban", note: second.id });
143 const visitor = getNote(app.db, record.id, second.id)!.visitor;
144 expect(isBanned(app.db, record.id, visitor)).toBe(true);
145 const afterBan = (await call(ownerRw, { app: "guild/board" })).value;
146 expect(afterBan.notes).toEqual([]);
147 expect(afterBan.banned.map((n: { id: string; banned: boolean }) => [n.id, n.banned])).toEqual([[second.id, true]]);
148
149 await call(ownerRw, { app: "guild/board", action: "unban", note: second.id });
150 expect(isBanned(app.db, record.id, visitor)).toBe(false);
151 expect((await call(ownerRw, { app: "guild/board" })).value.banned).toEqual([]);
152 });
153
154 test("a writer who is not an admin is refused, listing and acting", async () => {
155 expect(canWrite(app.db, repo, writer)).toBe(true);
156 expect(canAdmin(app.db, repo, writer)).toBe(false);
157 const note = await send("A note the writer cannot touch");
158 expect((await call(writerRw, { app: "guild/board" })).error).toMatch(/^forbidden/);
159 for (const action of ["hide", "ban", "unban"])
160 expect((await call(writerRw, { app: "guild/board", action, note: note.id })).error).toMatch(/^forbidden/);
161 expect(getNote(app.db, record.id, note.id)!.hidden).toBe(false);
162 });
163
164 test("an unknown app or note is not_found, an unknown action invalid", async () => {
165 expect((await call(ownerRw, { app: "guild/nothing" })).error).toMatch(/^not_found/);
166 expect((await call(ownerRw, { app: "guild/board", action: "hide", note: "nonote00000" })).error).toMatch(/^not_found/);
167 expect((await call(ownerRw, { app: "guild/board", action: "unban", note: "nonote00000" })).error).toMatch(/^not_found/);
168 expect((await call(ownerRw, { app: "guild/board", action: "delete", note: "x" })).error).toMatch(/^invalid/);
169 expect((await call(ownerRw, { app: "guild/board", action: "hide" })).error).toMatch(/^invalid/);
170 });
171 });
test/mcp.test.ts +1
@@ -263,6 +263,7 @@ describe("the forge over MCP", () => {
263263 "assigned",
264264 "assign",
265265 "app_members",
266 "app_notes",
266267 "egress_grants",
267268 "explore",
268269 "person",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs