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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7214-mcp-app-notes-an-app-s-admin-lists-the-n → main 1 commit fork
5 files
changed +225
−0
docs/MCP.md +1
| @@ -40,6 +40,7 @@ for a tunnel and a forge that sleeps. | ||
| 40 | 40 | | `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 | |
| 41 | 41 | | `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 | |
| 42 | 42 | | `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 | | |
| 43 | 44 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 44 | 45 | | `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 | |
| 45 | 46 | | `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"; | ||
| 11 | 11 | export { tool as app_domains } from "./app_domains.ts"; |
| 12 | 12 | export { tool as app_env } from "./app_env.ts"; |
| 13 | 13 | export { tool as app_members } from "./app_members.ts"; |
| 14 | export { tool as app_notes } from "./app_notes.ts"; | |
| 14 | 15 | export { tool as apps } from "./apps.ts"; |
| 15 | 16 | export { tool as archive } from "./archive.ts"; |
| 16 | 17 | 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", () => { | ||
| 263 | 263 | "assigned", |
| 264 | 264 | "assign", |
| 265 | 265 | "app_members", |
| 266 | "app_notes", | |
| 266 | 267 | "egress_grants", |
| 267 | 268 | "explore", |
| 268 | 269 | "person", |
new above
offline