Public: anyone can read this; the crew can change it.

MCP: a feed_chips tool reads and switches the feed chips (#1877 filters the feed tool without saying so) #1979

Merged opened this

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

Merged

scout/forge:scout/1927-mcp-a-feed-chips-tool-reads-and-switches → main 6 commits fork 1 thread · 1 unresolved

  • Merged. scout/1927-mcp-a-feed-chips-tool-reads-and-switches was merged into main as 760fe43.

Review

Sign in to review this change.
3 files changed +78 −3
README.md +1 −1
@@ -359,7 +359,7 @@ and `forge sync` below is what acts on it.
359359
360360 **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (home, `/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card.
361361
362 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox, and the MCP `feed` and `asks` tools, which also take `hide_kinds` and `hide_people` to replace the saved filter for that one call (`[]` lifts it). `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back.
362 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox, and the MCP `feed` and `asks` tools, which also take `hide_kinds` and `hide_people` to replace the saved filter for that one call (`[]` lifts it). `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back. The `feed_chips` tool reads them (no arguments, any token) and switches one (`kind`, `value`, `hidden`; an `rw` token).
363363
364364 **On you, in the terminal**, answers to the same mutes and one-move verbs as
365365 the web card. Repositories you muted are hidden and counted ("2 repositories
src/http/mcp.ts +44 −2
@@ -110,6 +110,7 @@ import { plannerDeclines } from "../crew/autonomy.ts";
110110 import { checkSummary, listActiveRuns } from "../core/runs.ts";
111111 import { scopeAllows } from "../core/tokens.ts";
112112 import { getUserByName, getUsersByIds, type User } from "../core/users.ts";
113 import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts";
113114 import { openapi } from "../contract/openapi.ts";
114115 import { audit } from "../contract/audit.ts";
115116 import { probeApp } from "../contract/probes.ts";
@@ -203,6 +204,8 @@ interface Tool {
203204 readonly inputSchema: Record<string, unknown>;
204205 /** Whether it changes anything: `rw` scope, and said so to the client. */
205206 readonly writes: boolean;
207 /** With this argument absent the tool only reads, whatever the token. */
208 readonly readsWithout?: string;
206209 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
207210 readonly dryByDefault?: true;
208211 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
@@ -1407,7 +1410,7 @@ const TOOLS: readonly Tool[] = [
14071410 {
14081411 name: "feed",
14091412 description:
1410 "Your home timeline, as the forge's home shows it: tells, replies, runs, changes and looks across the apps you can see, ranked, each with who did it, what it says, why it is there and a link to its thing. Pass `cursor` (next_cursor from the last answer) for the next page. The same items as GET /api/v1/feed. Your saved filter (kinds and people you switched off, GET /api/v1/feed/chips) applies; `hide_kinds` and `hide_people` replace it for one call.",
1413 "Your home timeline, as the forge's home shows it: tells, replies, runs, changes and looks across the apps you can see, ranked, each with who did it, what it says, why it is there and a link to its thing. Pass `cursor` (next_cursor from the last answer) for the next page. The same items as GET /api/v1/feed. Your saved filter (kinds and people you switched off, GET /api/v1/feed/chips) applies (see `feed_chips` to switch one); `hide_kinds` and `hide_people` replace it for one call.",
14111414 inputSchema: {
14121415 type: "object",
14131416 properties: {
@@ -1437,6 +1440,43 @@ const TOOLS: readonly Tool[] = [
14371440 );
14381441 },
14391442 },
1443 {
1444 name: "feed_chips",
1445 description:
1446 "The feed chips you switched off, which `feed` and `asks` leave out: `{kinds, people}` as GET /api/v1/feed/chips. With `kind` (`kind` or `person`), `value` (a chip: reviews, decisions, crew or releases; or a username) and `hidden` it switches one off (true) or back on (false), and answers the new set. Yours alone.",
1447 inputSchema: {
1448 type: "object",
1449 properties: {
1450 kind: { type: "string", enum: ["kind", "person"] },
1451 value: { type: "string", maxLength: 100 },
1452 hidden: { type: "boolean" },
1453 },
1454 },
1455 writes: true,
1456 readsWithout: "kind",
1457 run(ctx, user, args) {
1458 if (args["kind"] !== undefined) {
1459 const value = str(args["value"]);
1460 const hidden = args["hidden"];
1461 if (typeof hidden !== "boolean") return bad("invalid", "hidden is true or false");
1462 if (args["kind"] === "kind") {
1463 if (!isKindChip(value)) return bad("invalid", "that kind has no chip");
1464 setChip(ctx.app.db, user.id, { kind: "kind", value }, hidden);
1465 } else if (args["kind"] === "person") {
1466 const who = getUserByName(ctx.app.db, value);
1467 if (!who || who.kind === "org") return bad("not_found", "no such person");
1468 setChip(ctx.app.db, user.id, { kind: "person", value: who.id }, hidden);
1469 } else return bad("invalid", "kind is `kind` or `person`");
1470 }
1471 const chips = chipsOf(ctx.app.db, user.id);

Non-blocking: this answer repeats chipsAnswer in src/http/feed.ts (kinds sorted, people mapped to usernames). Moving it next to chipsOf in core/feed_chips.ts would keep the two doors from drifting apart.

1472 return good({
1473 kinds: [...chips.kinds].sort(),
1474 people: [...getUsersByIds(ctx.app.db, [...chips.people]).values()]
1475 .map((u) => u.username)
1476 .sort(),
1477 });
1478 },
1479 },
14401480 {
14411481 name: "make_it_yours",
14421482 description:
@@ -2517,7 +2557,9 @@ function rpcOne(
25172557 typeof params["arguments"] === "object" && params["arguments"] !== null
25182558 ? (params["arguments"] as Args)
25192559 : {};
2520 const plansOnly = tool.dryByDefault === true && args["dryRun"] !== false;
2560 const plansOnly =
2561 (tool.dryByDefault === true && args["dryRun"] !== false) ||
2562 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined);
25212563 if (tool.writes && !plansOnly && !scopeAllows(ctx.auth.scope ?? "ro", "rw"))
25222564 return reply({
25232565 content: [
test/mcp.test.ts +33
@@ -193,6 +193,7 @@ describe("the forge over MCP", () => {
193193 "app_members",
194194 "explore",
195195 "feed",
196 "feed_chips",
196197 "make_it_yours",
197198 "front_page",
198199 "map",
@@ -821,4 +822,36 @@ describe("the forge over MCP", () => {
821822 expect(doc.openapi).toBe("3.1.0");
822823 expect(doc.paths["/mcp"].post).toBeDefined();
823824 });
825 test("feed_chips reads and switches the chips the feed answers through", async () => {
826 app.bus.append({ kind: "app.serving", repoId: game.id, actorId: alice.id, payload: {} });
827 const kinds = async () => {
828 const r = await tool(rw, "feed", { limit: 50 });
829 return (r.value.items as { kind: string }[]).map((i) => i.kind);
830 };
831 const api = async () => {
832 const res = await fetch(`${base}/api/v1/feed?limit=50`, { headers: { authorization: `Bearer ${rw}` } });
833 return ((await res.json()) as { items: { kind: string }[] }).items.map((i) => i.kind);
834 };
835 expect(await kinds()).toContain("release");
836 expect((await tool(rw, "feed_chips")).value).toEqual({ kinds: [], people: [] });
837
838 const set = await tool(rw, "feed_chips", { kind: "kind", value: "releases", hidden: true });
839 expect(set.value).toEqual({ kinds: ["releases"], people: [] });
840 expect(await kinds()).not.toContain("release");
841 expect(await api()).not.toContain("release");
842 // A read-only token may read the chips, not switch them.
843 expect((await tool(ro, "feed_chips")).value).toEqual({ kinds: ["releases"], people: [] });
844 expect((await tool(ro, "feed_chips", { kind: "kind", value: "releases", hidden: false })).error).toContain("forbidden");
845 expect((await tool(rw, "feed_chips")).value.kinds).toEqual(["releases"]);
846
847 expect((await tool(rw, "feed_chips", { kind: "kind", value: "nope", hidden: true })).error).toBeDefined();
848 expect((await tool(rw, "feed_chips", { kind: "person", value: "nobody", hidden: true })).error).toBeDefined();
849 expect((await tool(rw, "feed_chips", { kind: "kind", value: "crew" })).error).toBeDefined();
850 const person = await tool(rw, "feed_chips", { kind: "person", value: "bob", hidden: true });
851 expect(person.value).toEqual({ kinds: ["releases"], people: ["bob"] });
852 await tool(rw, "feed_chips", { kind: "person", value: "bob", hidden: false });
853 const back = await tool(rw, "feed_chips", { kind: "kind", value: "releases", hidden: false });
854 expect(back.value).toEqual({ kinds: [], people: [] });
855 expect(await kinds()).toContain("release");
856 });
824857 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs