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

Merged opened this

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

Merged

mason/forge:mason/10908-a-note-s-sender-who-works-through-mcp-re → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/10908-a-note-s-sender-who-works-through-mcp-re was merged into main as 3346fbb.

Review

Sign in to review this change.
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):
111111 `POST /:owner/:name/app/notes` on the forge's origin (`web/studio/notes.ts`). The same moderation is an API for
112112 agents and scripts (`http/api/app_notes.ts`): `GET /api/v1/repos/:owner/:name/app/notes`
113113 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`
outdated

Move this how-to to docs/features/ and keep this line short.

115 (see docs/features/notes.md). They need an `rw` token (or a same-origin
115116 session) and admin rights on the repository; anyone else gets `forbidden`.
116117 Limits count the socket's client (`http/peer.ts`, with its trusted-proxy
117118 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
3333
3434 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.
3535
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
3647 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
143143 }
144144
145145 /** 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 })[] {
147147 const standing = new Map<string, boolean>();
148148 const speaks = (id: string): boolean => {
149149 let yes = standing.get(id);
@@ -155,8 +155,12 @@ function repliesTo(app: App, repo: Repo, issueId: string): NoteReply[] {
155155 return yes;
156156 };
157157 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 });
160164 }
161165
162166 function view(app: App, repo: Repo, forgeUrl: string | undefined, viewer: User | undefined, note: AppNote): NoteView | undefined {
@@ -341,6 +345,27 @@ export interface OwnerNote {
341345 readonly page: string;
342346 readonly at: string;
343347 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 untrusted as box 1 requires. Include them flagged untrusted: true.

366 const last = cut.charCodeAt(cut.length - 1);
367 if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
368 return `${cut}…`;
344369 }
345370
346371 /** 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 {
352377 function ownerView(app: App, repo: Repo, n: AppNote, banned: boolean): OwnerNote | undefined {
353378 const issue = getIssue(app.db, repo.id, n.issueNumber);
354379 if (issue === undefined) return undefined;
380 const all = repliesTo(app, repo, issue.id, true);
381 const latest = all.slice(-OWNER_REPLIES);
355382 return {
356383 id: n.id,
357384 by: n.name,
@@ -360,6 +387,8 @@ function ownerView(app: App, repo: Repo, n: AppNote, banned: boolean): OwnerNote
360387 page: pageOf(issue.body),
361388 at: n.createdAt,
362389 banned,
390 replies: latest.map(({ text, ...r }) => ({ ...r, body: clip(text) })),
391 earlierReplies: all.length - latest.length,
363392 };
364393 }
365394
src/http/mcp/tools/app_notes.ts +1 −1
@@ -15,7 +15,7 @@ const refused = (error: "forbidden" | "not_found" | "unavailable"): Answer =>
1515
1616 export const tool: Tool = {
1717 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",
1919 description: "Notes from an app's forge button and banned senders; hide a note, ban or unban its sender. Admin.",
2020 inputSchema: {
2121 type: "object",
test/mcp-app-notes.test.ts +52 −1
@@ -14,7 +14,8 @@ import { createRepo } from "../src/actions/repos.ts";
1414 import { feedback } from "../src/apps/feedback.ts";
1515 import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts";
1616 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";
1819 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
1920 import { canAdmin, canWrite, type Repo } from "../src/core/repos.ts";
2021 import { createToken } from "../src/core/tokens.ts";
@@ -151,6 +152,56 @@ describe("MCP app_notes", () => {
151152 expect((await call(ownerRw, { app: "guild/board" })).value.banned).toEqual([]);
152153 });
153154
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
154205 test("a writer who is not an admin is refused, listing and acting", async () => {
155206 expect(canWrite(app.db, repo, writer)).toBe(true);
156207 expect(canAdmin(app.db, repo, writer)).toBe(false);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs