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

Merged opened this

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

Merged

scout/forge:scout/6248-mcp-feed-dismiss-dismiss-or-mute-a-feed → main 2 commits fork

  • Merged. scout/6248-mcp-feed-dismiss-dismiss-or-mute-a-feed was merged into main as d36a4a7.

Review

Sign in to review this change.
7 files changed +187 −26
docs/MCP.md +1
@@ -75,6 +75,7 @@ for a tunnel and a forge that sleeps.
7575 | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | |
7676 | `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 | |
7777 | `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 |
7879 | `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 |
7980 | `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` | |
8081 | `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 {
1212 VARIANTS_MAX,
1313 } from "../actions/tell.ts";
1414 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";
1716 import {
1817 ITEM_ID,
1918 isInteractionKind,
@@ -26,7 +25,7 @@ import { drainQueue } from "../core/shares.ts";
2625 import { feedTag, hasQueuedShares } from "../feed/feed_tag.ts";
2726 import { holds } from "../web/feeds.ts";
2827 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";
3029 import {
3130 FEED_LIMIT_MAX,
3231 parseCursor,
@@ -240,33 +239,14 @@ export function feedRoutes(router: Router<Handler>): void {
240239 if (!isInteractionKind(kind))
241240 return apiError("invalid_kind", 422, "kind is not one the feed knows");
242241
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();
264244 const made = recordInteraction(db, {
265245 viewerId: viewer.id,
266246 itemId,
267247 kind,
268 repoId,
269 actorId,
248 repoId: target.repoId,
249 actorId: target.actorId,
270250 });
271251 return json({ id: made.id }, 201);
272252 });
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";
4848 export { tool as family } from "./family.ts";
4949 export { tool as feed } from "./feed.ts";
5050 export { tool as feed_chips } from "./feed_chips.ts";
51 export { tool as feed_dismiss } from "./feed_dismiss.ts";
5152 export { tool as flag_set } from "./flag_set.ts";
5253 export { tool as flow } from "./flow.ts";
5354 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", () => {
265265 "search",
266266 "feed",
267267 "feed_chips",
268 "feed_dismiss",
268269 "make_it_yours",
269270 "origin_apps",
270271 "front_page",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs