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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1927-mcp-a-feed-chips-tool-reads-and-switches → main 6 commits fork 1 thread · 1 unresolved
3 files
changed +78
−3
README.md +1 −1
| @@ -359,7 +359,7 @@ and `forge sync` below is what acts on it. | ||
| 359 | 359 | |
| 360 | 360 | **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. |
| 361 | 361 | |
| 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). | |
| 363 | 363 | |
| 364 | 364 | **On you, in the terminal**, answers to the same mutes and one-move verbs as |
| 365 | 365 | 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"; | ||
| 110 | 110 | import { checkSummary, listActiveRuns } from "../core/runs.ts"; |
| 111 | 111 | import { scopeAllows } from "../core/tokens.ts"; |
| 112 | 112 | import { getUserByName, getUsersByIds, type User } from "../core/users.ts"; |
| 113 | import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts"; | |
| 113 | 114 | import { openapi } from "../contract/openapi.ts"; |
| 114 | 115 | import { audit } from "../contract/audit.ts"; |
| 115 | 116 | import { probeApp } from "../contract/probes.ts"; |
| @@ -203,6 +204,8 @@ interface Tool { | ||
| 203 | 204 | readonly inputSchema: Record<string, unknown>; |
| 204 | 205 | /** Whether it changes anything: `rw` scope, and said so to the client. */ |
| 205 | 206 | readonly writes: boolean; |
| 207 | /** With this argument absent the tool only reads, whatever the token. */ | |
| 208 | readonly readsWithout?: string; | |
| 206 | 209 | /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */ |
| 207 | 210 | readonly dryByDefault?: true; |
| 208 | 211 | run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer; |
| @@ -1407,7 +1410,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1407 | 1410 | { |
| 1408 | 1411 | name: "feed", |
| 1409 | 1412 | 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.", | |
| 1411 | 1414 | inputSchema: { |
| 1412 | 1415 | type: "object", |
| 1413 | 1416 | properties: { |
| @@ -1437,6 +1440,43 @@ const TOOLS: readonly Tool[] = [ | ||
| 1437 | 1440 | ); |
| 1438 | 1441 | }, |
| 1439 | 1442 | }, |
| 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); | |
| 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 | }, | |
| 1440 | 1480 | { |
| 1441 | 1481 | name: "make_it_yours", |
| 1442 | 1482 | description: |
| @@ -2517,7 +2557,9 @@ function rpcOne( | ||
| 2517 | 2557 | typeof params["arguments"] === "object" && params["arguments"] !== null |
| 2518 | 2558 | ? (params["arguments"] as Args) |
| 2519 | 2559 | : {}; |
| 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); | |
| 2521 | 2563 | if (tool.writes && !plansOnly && !scopeAllows(ctx.auth.scope ?? "ro", "rw")) |
| 2522 | 2564 | return reply({ |
| 2523 | 2565 | content: [ |
test/mcp.test.ts +33
| @@ -193,6 +193,7 @@ describe("the forge over MCP", () => { | ||
| 193 | 193 | "app_members", |
| 194 | 194 | "explore", |
| 195 | 195 | "feed", |
| 196 | "feed_chips", | |
| 196 | 197 | "make_it_yours", |
| 197 | 198 | "front_page", |
| 198 | 199 | "map", |
| @@ -821,4 +822,36 @@ describe("the forge over MCP", () => { | ||
| 821 | 822 | expect(doc.openapi).toBe("3.1.0"); |
| 822 | 823 | expect(doc.paths["/mcp"].post).toBeDefined(); |
| 823 | 824 | }); |
| 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 | }); | |
| 824 | 857 | }); |
new above
offline
Non-blocking: this answer repeats
chipsAnswerinsrc/http/feed.ts(kinds sorted, people mapped to usernames). Moving it next tochipsOfincore/feed_chips.tswould keep the two doors from drifting apart.