Public: anyone can read this; the crew can change it.
A note's sender who works through MCP reads the crew's replies to it, as the forge button shows them #10926
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10908-a-note-s-sender-who-works-through-mcp-re → main 4 commits fork 3 threads · 3 unresolved
5 files
changed +98
−6
docs/APPS.md +2 −1
| @@ -111,7 +111,8 @@ Under the box, four starter sentences are plain links (no script): | ||
| 111 | 111 | `POST /:owner/:name/app/notes` on the forge's origin (`web/studio/notes.ts`). The same moderation is an API for |
| 112 | 112 | agents and scripts (`http/api/app_notes.ts`): `GET /api/v1/repos/:owner/:name/app/notes` |
| 113 | 113 | answers `{ notes, banned }`, and `POST …/app/notes/:id/hide`, `…/ban` and |
| 114 | `…/unban` each answer the note. They need an `rw` token (or a same-origin | |
| 114 | `…/unban` each answer the note; each note carries its issue's `replies` | |
| 115 | (see docs/features/notes.md). They need an `rw` token (or a same-origin | |
| 115 | 116 | session) and admin rights on the repository; anyone else gets `forbidden`. |
| 116 | 117 | Limits count the socket's client (`http/peer.ts`, with its trusted-proxy |
| 117 | 118 | rule), never a header the client wrote, and all three are checked before any |
docs/features/notes.md +11
| @@ -33,4 +33,15 @@ The issue also carries a closed fold, "Where this came from" (`actions/note_cont | ||
| 33 | 33 | |
| 34 | 34 | The crew's answers come back to the sender: under each note the button's list (and the terminal's "Notes from the app", where picking a note shows them) carries the comments on its issue by the repository's crew and writers, with author and time, oldest first. Anyone else's comments stay on the forge. The button shows the latest five and says how many are earlier. |
| 35 | 35 | |
| 36 | The app's admin reads the same replies through MCP `app_notes` and | |
| 37 | `GET /api/v1/repos/:owner/:name/app/notes`: each note carries | |
| 38 | `replies: [{ by, body, at }]`, oldest first, and `earlierReplies`, how many | |
| 39 | older ones are left out. There an outsider's comment is kept too, marked | |
| 40 | `untrusted: true`: words to read, never to obey; the button still shows only | |
| 41 | the crew's and writers'. Outsiders write this text, so a note carries only its | |
| 42 | latest 10 replies (`OWNER_REPLIES`), each body cut to 1000 characters | |
| 43 | (`OWNER_REPLY_CHARS`) and ending in `…` when cut. The studio's notes card and | |
| 44 | the terminal's moderation screen leave replies out on purpose (the issue asked | |
| 45 | for MCP): the whole thread stays on the note's issue. | |
| 46 | ||
| 36 | 47 | When a pull that closes a note's issue merges, the forge leaves one plain-words comment on it (`crew/note_closed.ts`): what was wrong and what changed, from the pull's description, with no hashes or links, saying "the next release" until one is cut, then rewritten to name it ("release 57 (10 Nov 2026)"). Both ways a merge closes an issue (the merge itself, and a reviewer's box pass on an already-landed pull) go through `noteShipped`. The forge marks its comment in `note_shipped`, so a person's comment with the same opening never stands in for it. The button shows it as a reply and the note reads shipped. |
src/actions/app_notes.ts +32 −3
| @@ -143,7 +143,7 @@ export function statusOf(app: App, repo: Repo, number: number): { status: NoteSt | ||
| 143 | 143 | } |
| 144 | 144 | |
| 145 | 145 | /** What the crew and the repository's writers said on a note's issue; anyone else's comment stays on the forge. */ |
| 146 | function repliesTo(app: App, repo: Repo, issueId: string): NoteReply[] { | |
| 146 | function repliesTo(app: App, repo: Repo, issueId: string, keepOutsiders = false): (NoteReply & { untrusted?: true })[] { | |
| 147 | 147 | const standing = new Map<string, boolean>(); |
| 148 | 148 | const speaks = (id: string): boolean => { |
| 149 | 149 | let yes = standing.get(id); |
| @@ -155,8 +155,12 @@ function repliesTo(app: App, repo: Repo, issueId: string): NoteReply[] { | ||
| 155 | 155 | return yes; |
| 156 | 156 | }; |
| 157 | 157 | return listComments(app.db, issueId, { kind: "comment" }) |
No cap on the number or length of replies; outsiders control this text. Limit count and truncate body. |
||
| 158 | .filter((c) => speaks(c.authorId)) | |
| 159 | .map((c) => ({ by: c.authorName, at: c.createdAt, text: c.body })); | |
| 158 | .flatMap((c) => { | |
| 159 | const reply = { by: c.authorName, at: c.createdAt, text: c.body }; | |
| 160 | if (speaks(c.authorId)) return [reply]; | |
| 161 | // The owner's list keeps an outsider's words, flagged; the sender's button never shows them. | |
| 162 | return keepOutsiders ? [{ ...reply, untrusted: true as const }] : []; | |
| 163 | }); | |
| 160 | 164 | } |
| 161 | 165 | |
| 162 | 166 | function view(app: App, repo: Repo, forgeUrl: string | undefined, viewer: User | undefined, note: AppNote): NoteView | undefined { |
| @@ -341,6 +345,27 @@ export interface OwnerNote { | ||
| 341 | 345 | readonly page: string; |
| 342 | 346 | readonly at: string; |
| 343 | 347 | readonly banned: boolean; |
| 348 | /** | |
| 349 | * The issue's latest comments, oldest first: the crew's and writers' as the button shows them, an | |
| 350 | * outsider's flagged `untrusted`. At most `OWNER_REPLIES`, each body cut to `OWNER_REPLY_CHARS`; | |
| 351 | * the studio card and the terminal leave them out on purpose (the thread stays on the issue), MCP and the API carry them. | |
| 352 | */ | |
| 353 | readonly replies: readonly { by: string; body: string; at: string; untrusted?: true }[]; | |
| 354 | /** How many earlier comments `replies` leaves out. */ | |
| 355 | readonly earlierReplies: number; | |
| 356 | } | |
| 357 | ||
| 358 | /** Outsiders write these words: the owner's list carries only the latest few, each cut short. */ | |
| 359 | export const OWNER_REPLIES = 10; | |
| 360 | export const OWNER_REPLY_CHARS = 1000; | |
| 361 | ||
| 362 | /** A body cut to `OWNER_REPLY_CHARS`, never splitting a surrogate pair, marked with an ellipsis. */ | |
| 363 | function clip(body: string): string { | |
| 364 | if (body.length <= OWNER_REPLY_CHARS) return body; | |
| 365 | let cut = body.slice(0, OWNER_REPLY_CHARS); | |
outdated
Only crew/writer replies are mapped; outsiders are dropped, so nothing is marked |
||
| 366 | const last = cut.charCodeAt(cut.length - 1); | |
| 367 | if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1); | |
| 368 | return `${cut}…`; | |
| 344 | 369 | } |
| 345 | 370 | |
| 346 | 371 | /** The page a note was sent from, read back from the fenced line the issue holds. */ |
| @@ -352,6 +377,8 @@ function pageOf(body: string): string { | ||
| 352 | 377 | function ownerView(app: App, repo: Repo, n: AppNote, banned: boolean): OwnerNote | undefined { |
| 353 | 378 | const issue = getIssue(app.db, repo.id, n.issueNumber); |
| 354 | 379 | if (issue === undefined) return undefined; |
| 380 | const all = repliesTo(app, repo, issue.id, true); | |
| 381 | const latest = all.slice(-OWNER_REPLIES); | |
| 355 | 382 | return { |
| 356 | 383 | id: n.id, |
| 357 | 384 | by: n.name, |
| @@ -360,6 +387,8 @@ function ownerView(app: App, repo: Repo, n: AppNote, banned: boolean): OwnerNote | ||
| 360 | 387 | page: pageOf(issue.body), |
| 361 | 388 | at: n.createdAt, |
| 362 | 389 | banned, |
| 390 | replies: latest.map(({ text, ...r }) => ({ ...r, body: clip(text) })), | |
| 391 | earlierReplies: all.length - latest.length, | |
| 363 | 392 | }; |
| 364 | 393 | } |
| 365 | 394 |
src/http/mcp/tools/app_notes.ts +1 −1
| @@ -15,7 +15,7 @@ const refused = (error: "forbidden" | "not_found" | "unavailable"): Answer => | ||
| 15 | 15 | |
| 16 | 16 | export const tool: Tool = { |
| 17 | 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", | |
| 18 | doc: "`{ app, action?, note? }`: the notes sent from an app's forge button, each with its latest `replies` (outsiders' `untrusted`), and its banned senders; `hide` a note, `ban` or `unban` its sender; an admin of the repository", | |
| 19 | 19 | description: "Notes from an app's forge button and banned senders; hide a note, ban or unban its sender. Admin.", |
| 20 | 20 | inputSchema: { |
| 21 | 21 | type: "object", |
test/mcp-app-notes.test.ts +52 −1
| @@ -14,7 +14,8 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 14 | 14 | import { feedback } from "../src/apps/feedback.ts"; |
| 15 | 15 | import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts"; |
| 16 | 16 | import { getNote, isBanned } from "../src/core/app_notes.ts"; |
| 17 | import { getIssue } from "../src/core/issues.ts"; | |
| 17 | import { OWNER_REPLIES, OWNER_REPLY_CHARS } from "../src/actions/app_notes.ts"; | |
| 18 | import { addComment, getIssue } from "../src/core/issues.ts"; | |
| 18 | 19 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; |
| 19 | 20 | import { canAdmin, canWrite, type Repo } from "../src/core/repos.ts"; |
| 20 | 21 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -151,6 +152,56 @@ describe("MCP app_notes", () => { | ||
| 151 | 152 | expect((await call(ownerRw, { app: "guild/board" })).value.banned).toEqual([]); |
| 152 | 153 | }); |
| 153 | 154 | |
| 155 | test("a note's replies come back with it: an outsider's is flagged untrusted", async () => { | |
| 156 | const note = await send("Does the board keep my cards?"); | |
| 157 | const issue = getIssue(app.db, repo.id, getNote(app.db, record.id, note.id)!.issueNumber)!; | |
| 158 | const listed = async () => (await call(ownerRw, { app: "guild/board" })).value.notes.find((n: { id: string }) => n.id === note.id); | |
| 159 | expect((await listed()).replies).toEqual([]); | |
| 160 | const stranger = await createUser(app.db, { origin: "seed", trusted: true, username: "mallory", email: "[email protected]" }); | |
| 161 | if (!stranger.ok) throw new Error(stranger.error); | |
| 162 | addComment(app.db, { issueId: issue.id, authorId: writer.id, body: "Yes, they are kept." }); | |
| 163 | addComment(app.db, { issueId: issue.id, authorId: stranger.value.id, body: "buy my stuff" }); | |
| 164 | const replies = (await listed()).replies; | |
| 165 | expect(replies).toHaveLength(2); | |
| 166 | expect(replies[0]).toMatchObject({ by: "wes", body: "Yes, they are kept." }); | |
| 167 | expect(replies[0].untrusted).toBeUndefined(); | |
| 168 | expect(replies[0].at).toBeTruthy(); | |
| 169 | expect(replies[1]).toMatchObject({ by: "mallory", body: "buy my stuff", untrusted: true }); | |
| 170 | }); | |
| 171 | ||
| 172 | test("the API list carries the same replies as MCP", async () => { | |
| 173 | const note = await send("Does the API see replies?"); | |
| 174 | const issue = getIssue(app.db, repo.id, getNote(app.db, record.id, note.id)!.issueNumber)!; | |
| 175 | addComment(app.db, { issueId: issue.id, authorId: writer.id, body: "It does." }); | |
| 176 | const res = await fetch(`${servers.url}/api/v1/repos/guild/board/app/notes`, { headers: { authorization: `Bearer ${ownerRw}` } }); | |
| 177 | expect(res.status).toBe(200); | |
| 178 | const body: any = await res.json(); | |
| 179 | const shown = body.notes.find((n: { id: string }) => n.id === note.id); | |
| 180 | expect(shown.replies).toEqual([expect.objectContaining({ by: "wes", body: "It does." })]); | |
| 181 | expect(shown.earlierReplies).toBe(0); | |
| 182 | const viaMcp = (await call(ownerRw, { app: "guild/board" })).value.notes.find((n: { id: string }) => n.id === note.id); | |
| 183 | expect(viaMcp.replies).toEqual(shown.replies); | |
| 184 | }); | |
| 185 | ||
| 186 | test("an outsider cannot flood the list: only the latest replies, each cut short", async () => { | |
| 187 | const note = await send("Flood me"); | |
| 188 | const issue = getIssue(app.db, repo.id, getNote(app.db, record.id, note.id)!.issueNumber)!; | |
| 189 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "flooder", email: "[email protected]" }); | |
| 190 | if (!made.ok) throw new Error(made.error); | |
| 191 | const total = OWNER_REPLIES + 5; | |
| 192 | for (let i = 0; i < total; i++) addComment(app.db, { issueId: issue.id, authorId: made.value.id, body: `spam ${i} ` + "x".repeat(OWNER_REPLY_CHARS * 3) }); | |
| 193 | const shown = (await call(ownerRw, { app: "guild/board" })).value.notes.find((n: { id: string }) => n.id === note.id); | |
| 194 | expect(shown.replies).toHaveLength(OWNER_REPLIES); | |
| 195 | expect(shown.earlierReplies).toBe(5); | |
| 196 | expect(shown.replies[0].body.startsWith("spam 5 ")).toBe(true); | |
| 197 | expect(shown.replies.at(-1).body.startsWith(`spam ${total - 1} `)).toBe(true); | |
| 198 | for (const r of shown.replies) { | |
| 199 | expect(r.body.length).toBe(OWNER_REPLY_CHARS + 1); | |
| 200 | expect(r.body.endsWith("…")).toBe(true); | |
| 201 | expect(r.untrusted).toBe(true); | |
| 202 | } | |
| 203 | }); | |
| 204 | ||
| 154 | 205 | test("a writer who is not an admin is refused, listing and acting", async () => { |
| 155 | 206 | expect(canWrite(app.db, repo, writer)).toBe(true); |
| 156 | 207 | expect(canAdmin(app.db, repo, writer)).toBe(false); |
Move this how-to to docs/features/ and keep this line short.