Public: anyone can read this; the crew can change it.
MCP `feed_dismiss`: dismiss or mute a feed item, as the card's menu does #6967
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6248-mcp-feed-dismiss-dismiss-or-mute-a-feed → main 2 commits fork
7 files
changed +187
−26
docs/MCP.md +1
| @@ -75,6 +75,7 @@ for a tunnel and a forge that sleeps. | ||
| 75 | 75 | | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | | |
| 76 | 76 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's "Since you were here" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | | |
| 77 | 77 | | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes | |
| 78 | | `feed_dismiss` | `{ item, kind, app? }`: dismiss a feed item or mute its app, as the card's menu does (`kind` dismissed or muted); `feed` then leaves it out; `app` (`owner/name`) names the app for an item that is not an event; answers `{ id }` | yes | | |
| 78 | 79 | | `flag_set` | `{ name, value?, clear? }`: set a declared flag's live value (`value` is `{ forge?, users?, roles?, percent? }`) or `clear` it to its default, with the Flags page's checks and refusals; a forge admin's (never an agent), an admin token; answers `{ name, value, says }` | yes | |
| 79 | 80 | | `flow` | the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing` | | |
| 80 | 81 | | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes | |
src/feed/interact.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * What an act on a feed item is about, and whether the viewer may act on it. | |
| 3 | * The route and the MCP tool both ask here, so they refuse the same things. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { Database } from "bun:sqlite"; | |
| 7 | import { audienceFilter } from "../core/event_audience.ts"; | |
| 8 | import { getEvent } from "../core/events.ts"; | |
| 9 | import { canRead, getRepo, getRepoById } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | ||
| 12 | export interface InteractionTarget { | |
| 13 | readonly repoId: string; | |
| 14 | readonly actorId: string | null; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * The repository (and, for an event, the actor) an item is about: read from | |
| 19 | * the event through the visibility the feed used, or from the named | |
| 20 | * `owner/name` otherwise. Anything the viewer cannot read is undefined. | |
| 21 | */ | |
| 22 | export function interactionTarget( | |
| 23 | db: Database, | |
| 24 | viewer: User, | |
| 25 | itemId: string, | |
| 26 | app: string, | |
| 27 | ): InteractionTarget | undefined { | |
| 28 | if (itemId.startsWith("event:")) { | |
| 29 | const event = getEvent(db, itemId.slice("event:".length)); | |
| 30 | const repo = event?.repoId ? getRepoById(db, event.repoId) : undefined; | |
| 31 | if (!event || !repo || !canRead(db, repo, viewer)) return undefined; | |
| 32 | if (!audienceFilter(db, viewer)(event)) return undefined; | |
| 33 | return { repoId: repo.id, actorId: event.actorId }; | |
| 34 | } | |
| 35 | const [owner, name, extra] = app.split("/"); | |
| 36 | const repo = | |
| 37 | owner && name && extra === undefined ? getRepo(db, owner, name) : undefined; | |
| 38 | if (!repo || !canRead(db, repo, viewer)) return undefined; | |
| 39 | return { repoId: repo.id, actorId: null }; | |
| 40 | } |
src/http/feed.ts +6 −26
| @@ -12,8 +12,7 @@ import { | ||
| 12 | 12 | VARIANTS_MAX, |
| 13 | 13 | } from "../actions/tell.ts"; |
| 14 | 14 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 15 | import { audienceFilter } from "../core/event_audience.ts"; | |
| 16 | import { getEvent } from "../core/events.ts"; | |
| 15 | import { interactionTarget } from "../feed/interact.ts"; | |
| 17 | 16 | import { |
| 18 | 17 | ITEM_ID, |
| 19 | 18 | isInteractionKind, |
| @@ -26,7 +25,7 @@ import { drainQueue } from "../core/shares.ts"; | ||
| 26 | 25 | import { feedTag, hasQueuedShares } from "../feed/feed_tag.ts"; |
| 27 | 26 | import { holds } from "../web/feeds.ts"; |
| 28 | 27 | import { share, RECIPIENTS_MAX } from "../feed/share.ts"; |
| 29 | import { canRead, getRepo, getRepoById } from "../core/repos.ts"; | |
| 28 | import { canRead, getRepo } from "../core/repos.ts"; | |
| 30 | 29 | import { |
| 31 | 30 | FEED_LIMIT_MAX, |
| 32 | 31 | parseCursor, |
| @@ -240,33 +239,14 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 240 | 239 | if (!isInteractionKind(kind)) |
| 241 | 240 | return apiError("invalid_kind", 422, "kind is not one the feed knows"); |
| 242 | 241 | |
| 243 | // What the act is about is read from the item when the item is an event | |
| 244 | // — through the same visibility the feed used — and from the named | |
| 245 | // repository otherwise. Anything the viewer cannot read is not found. | |
| 246 | let repoId: string | null = null; | |
| 247 | let actorId: string | null = null; | |
| 248 | if (itemId.startsWith("event:")) { | |
| 249 | const event = getEvent(db, itemId.slice("event:".length)); | |
| 250 | const repo = event?.repoId ? getRepoById(db, event.repoId) : undefined; | |
| 251 | if (!event || !repo || !canRead(db, repo, viewer)) return notFound(); | |
| 252 | if (!audienceFilter(db, viewer)(event)) return notFound(); | |
| 253 | repoId = repo.id; | |
| 254 | actorId = event.actorId; | |
| 255 | } else { | |
| 256 | const [owner, name, extra] = str(input.app).split("/"); | |
| 257 | const repo = | |
| 258 | owner && name && extra === undefined | |
| 259 | ? getRepo(db, owner, name) | |
| 260 | : undefined; | |
| 261 | if (!repo || !canRead(db, repo, viewer)) return notFound(); | |
| 262 | repoId = repo.id; | |
| 263 | } | |
| 242 | const target = interactionTarget(db, viewer, itemId, str(input.app)); | |
| 243 | if (!target) return notFound(); | |
| 264 | 244 | const made = recordInteraction(db, { |
| 265 | 245 | viewerId: viewer.id, |
| 266 | 246 | itemId, |
| 267 | 247 | kind, |
| 268 | repoId, | |
| 269 | actorId, | |
| 248 | repoId: target.repoId, | |
| 249 | actorId: target.actorId, | |
| 270 | 250 | }); |
| 271 | 251 | return json({ id: made.id }, 201); |
| 272 | 252 | }); |
src/http/mcp/tools/feed_dismiss.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import { ITEM_ID, recordInteraction } from "../../../core/interactions.ts"; | |
| 2 | import { interactionTarget } from "../../../feed/interact.ts"; | |
| 3 | import { good, bad, type Tool, str } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "feed_dismiss", | |
| 7 | doc: "`{ item, kind, app? }`: dismiss a feed item or mute its app, as the card's menu does (`kind` dismissed or muted); `feed` then leaves it out; `app` (`owner/name`) names the app for an item that is not an event; answers `{ id }`", | |
| 8 | description: "Dismiss a feed item or mute its app; `feed` then leaves it out.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { | |
| 12 | item: { type: "string" }, | |
| 13 | kind: { type: "string", enum: ["dismissed", "muted"] }, | |
| 14 | app: { type: "string" }, | |
| 15 | }, | |
| 16 | required: ["item", "kind"], | |
| 17 | }, | |
| 18 | writes: true, | |
| 19 | run(ctx, user, args) { | |
| 20 | const item = str(args["item"]); | |
| 21 | const kind = args["kind"]; | |
| 22 | if (!ITEM_ID.test(item)) return bad("invalid", "item is a feed item id"); | |
| 23 | if (kind !== "dismissed" && kind !== "muted") | |
| 24 | return bad("invalid", "kind is dismissed or muted"); | |
| 25 | const target = interactionTarget(ctx.app.db, user, item, str(args["app"])); | |
| 26 | if (!target) return bad("not_found", "no such item"); | |
| 27 | const made = recordInteraction(ctx.app.db, { | |
| 28 | viewerId: user.id, | |
| 29 | itemId: item, | |
| 30 | kind, | |
| 31 | repoId: target.repoId, | |
| 32 | actorId: target.actorId, | |
| 33 | }); | |
| 34 | return good({ id: made.id }); | |
| 35 | }, | |
| 36 | }; |
src/http/mcp/tools/index.ts +1
| @@ -48,6 +48,7 @@ export { tool as explore } from "./explore.ts"; | ||
| 48 | 48 | export { tool as family } from "./family.ts"; |
| 49 | 49 | export { tool as feed } from "./feed.ts"; |
| 50 | 50 | export { tool as feed_chips } from "./feed_chips.ts"; |
| 51 | export { tool as feed_dismiss } from "./feed_dismiss.ts"; | |
| 51 | 52 | export { tool as flag_set } from "./flag_set.ts"; |
| 52 | 53 | export { tool as flow } from "./flow.ts"; |
| 53 | 54 | export { tool as follow_mode } from "./follow_mode.ts"; |
test/mcp-feed-dismiss.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** `feed_dismiss` does what the card's menu does: `feed` then leaves the item out; a bad kind or an unseen item is refused. */ | |
| 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 { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { readFeed } from "../src/feed/feed.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | ||
| 14 | describe("MCP feed_dismiss", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let alice: User; | |
| 19 | let bob: User; | |
| 20 | let aliceTok: string; | |
| 21 | let bobTok: string; | |
| 22 | let id = 1; | |
| 23 | ||
| 24 | const call = async (token: string, name: string, args: unknown) => { | |
| 25 | const res = await fetch(`${servers.url}/mcp`, { | |
| 26 | method: "POST", | |
| 27 | headers: { | |
| 28 | "content-type": "application/json", | |
| 29 | accept: "application/json, text/event-stream", | |
| 30 | authorization: `Bearer ${token}`, | |
| 31 | }, | |
| 32 | body: JSON.stringify({ | |
| 33 | jsonrpc: "2.0", | |
| 34 | id: id++, | |
| 35 | method: "tools/call", | |
| 36 | params: { name, arguments: args }, | |
| 37 | }), | |
| 38 | }); | |
| 39 | const body: any = await res.json(); | |
| 40 | const text: string = body.result.content[0].text; | |
| 41 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 42 | }; | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-feed-dismiss-")); | |
| 46 | app = createApp({ dataDir, port: 0 }); | |
| 47 | servers = await serve(app); | |
| 48 | const mk = async (username: string, first = false) => { | |
| 49 | const u = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | username, | |
| 53 | email: `${username}@forge.local`, | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: first, | |
| 56 | }); | |
| 57 | if (!u.ok) throw new Error(u.error); | |
| 58 | promote(app.db, u.value); | |
| 59 | return u.value; | |
| 60 | }; | |
| 61 | await mk("founder", true); | |
| 62 | alice = await mk("alice"); | |
| 63 | bob = await mk("bob"); | |
| 64 | aliceTok = createToken(app.db, alice.id, "rw", "rw").token; | |
| 65 | bobTok = createToken(app.db, bob.id, "rw", "rw").token; | |
| 66 | const open = await createRepo(app, alice, { name: "open", visibility: "public" }); | |
| 67 | const secret = await createRepo(app, alice, { name: "secret", visibility: "private" }); | |
| 68 | if (!open.ok || !secret.ok) throw new Error("repos"); | |
| 69 | for (const [repo, summary] of [[open.value, "up"], [secret.value, "boom"]] as const) | |
| 70 | app.bus.append({ | |
| 71 | kind: "run.finished", | |
| 72 | repoId: repo.id, | |
| 73 | actorId: alice.id, | |
| 74 | payload: { kind: "deploy", state: "ok", summary, runId: `r-${summary}` }, | |
| 75 | }); | |
| 76 | }); | |
| 77 | ||
| 78 | afterAll(async () => { | |
| 79 | servers.stop(); | |
| 80 | app.close(); | |
| 81 | await rm(dataDir, { recursive: true, force: true }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a dismissed item is no longer in the feed", async () => { | |
| 85 | const run = readFeed(app.db, bob).items.find((i) => i.id.startsWith("event:") && i.app === "alice/open")!; | |
| 86 | expect(run).toBeDefined(); | |
| 87 | const done = await call(bobTok, "feed_dismiss", { item: run.id, kind: "dismissed" }); | |
| 88 | expect(done.value.id).toBeString(); | |
| 89 | const listed = await call(bobTok, "feed", {}); | |
| 90 | expect(listed.value.items.some((i: { id: string }) => i.id === run.id)).toBe(false); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a bad kind or item is refused, and so is an item the caller cannot see", async () => { | |
| 94 | const secret = readFeed(app.db, alice).items.find((i) => i.app === "alice/secret")!; | |
| 95 | expect(secret).toBeDefined(); | |
| 96 | expect((await call(aliceTok, "feed_dismiss", { item: secret.id, kind: "opened" })).error).toBeDefined(); | |
| 97 | expect((await call(aliceTok, "feed_dismiss", { item: "../x", kind: "muted" })).error).toBeDefined(); | |
| 98 | expect((await call(bobTok, "feed_dismiss", { item: secret.id, kind: "dismissed" })).error).toContain("no such item"); | |
| 99 | expect((await call(bobTok, "feed_dismiss", { item: "ask:look:x", kind: "muted", app: "alice/secret" })).error).toContain("no such item"); | |
| 100 | expect((await call(bobTok, "feed_dismiss", { item: "ask:look:x", kind: "muted", app: "alice/open" })).value.id).toBeString(); | |
| 101 | }); | |
| 102 | }); |
test/mcp.test.ts +1
| @@ -265,6 +265,7 @@ describe("the forge over MCP", () => { | ||
| 265 | 265 | "search", |
| 266 | 266 | "feed", |
| 267 | 267 | "feed_chips", |
| 268 | "feed_dismiss", | |
| 268 | 269 | "make_it_yours", |
| 269 | 270 | "origin_apps", |
| 270 | 271 | "front_page", |
new above
offline