Public: anyone can read this; the crew can change it.
MCP feed and asks take the filter the owner saved, and the manifest says so #1935
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1599-mcp-feed-and-asks-take-the-filter-the-ow → main 9 commits fork 4 threads · 4 unresolved
README.md +1 −1
| @@ -324,7 +324,7 @@ and `forge sync` below is what acts on it. | ||
| 324 | 324 | |
| 325 | 325 | **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. |
| 326 | 326 | |
| 327 | **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. `/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. | |
| 327 | **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. | |
| 328 | 328 | |
| 329 | 329 | **On you, in the terminal**, answers to the same mutes and one-move verbs as |
| 330 | 330 | the web card. Repositories you muted are hidden and counted ("2 repositories |
src/core/feed_chips.ts +31
| @@ -51,3 +51,34 @@ export function setChip( | ||
| 51 | 51 | "DELETE FROM feed_chip WHERE viewer_id = ?1 AND kind = ?2 AND value = ?3", |
| 52 | 52 | ).run(viewerId, chip.kind, chip.value); |
| 53 | 53 | } |
| 54 | ||
| 55 | /** | |
| 56 | * A filter given as arguments (`hide_kinds`, `hide_people`, the latter by | |
| 57 | * username): the chips it names, or why it is not one. Absent is undefined — | |
| 58 | * the viewer's saved chips stand; given, it replaces them for that call. | |
| 59 | */ | |
| 60 | export function parseChipArgs( | |
| 61 | args: { hide_kinds?: unknown; hide_people?: unknown }, | |
| 62 | userId: (name: string) => string | undefined, | |
| 63 | ): Chips | undefined | { error: string } { | |
| 64 | const { hide_kinds: k, hide_people: p } = args; | |
| 65 | if (k === undefined && p === undefined) return undefined; | |
| 66 | const kinds = new Set<KindChip>(); | |
| 67 | const people = new Set<string>(); | |
| 68 | const list = (v: unknown): unknown[] | undefined => | |
| 69 | v === undefined ? [] : Array.isArray(v) && v.length <= 50 ? v : undefined; | |
| 70 | const ks = list(k); | |
| 71 | const ps = list(p); | |
| 72 | if (ks === undefined || ps === undefined) | |
| 73 | return { error: "hide_kinds and hide_people are lists of at most 50" }; | |
| 74 | for (const v of ks) { | |
| 75 | if (!isKindChip(v)) return { error: `hide_kinds are ${KIND_CHIPS.join(", ")}` }; | |
| 76 | kinds.add(v); | |
| 77 | } | |
| 78 | for (const v of ps) { | |
| 79 | const id = typeof v === "string" ? userId(v) : undefined; | |
| 80 | if (id === undefined) return { error: "hide_people are usernames of people or agents here" }; | |
| 81 | people.add(id); | |
| 82 | } | |
| 83 | return { kinds, people }; | |
| 84 | } |
src/feed/feed.ts +4 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { chipsOf } from "../core/feed_chips.ts"; | |
| 10 | import { chipsOf, type Chips } from "../core/feed_chips.ts"; | |
| 11 | 11 | import { chipHides } from "./chips.ts"; |
| 12 | 12 | import { dismissedItems, mutedRepos } from "../core/interactions.ts"; |
| 13 | 13 | import { affinityFor } from "./affinity.ts"; |
| @@ -39,6 +39,7 @@ export function readFeed( | ||
| 39 | 39 | limit?: number; |
| 40 | 40 | cursor?: { score: number; id: string }; |
| 41 | 41 | now?: number; |
| 42 | chips?: Chips; | |
| 42 | 43 | /** The home pins asks above the feed: they must not fill its page. */ |
| 43 | 44 | withoutAsks?: boolean; |
| 44 | 45 | } = {}, |
| @@ -47,14 +48,14 @@ export function readFeed( | ||
| 47 | 48 | const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX); |
| 48 | 49 | const muted = mutedRepos(db, viewer.id); |
| 49 | 50 | const dismissed = dismissedItems(db, viewer.id); |
| 50 | const chips = chipsOf(db, viewer.id); | |
| 51 | const chips = options.chips ?? chipsOf(db, viewer.id); | |
| 51 | 52 | drainQueue(db, viewer.id, now); |
| 52 | 53 | const shared = sharedCandidates(db, viewer, now).filter( |
| 53 | 54 | (c) => !dismissed.has(c.itemId), |
| 54 | 55 | ); |
| 55 | 56 | const boosted = new Set(shared.map((c) => c.itemId)); |
| 56 | 57 | const candidates = [ |
| 57 | ...candidatesFor(db, viewer, now).filter((c) => !boosted.has(c.id)), | |
| 58 | ...candidatesFor(db, viewer, now, chips).filter((c) => !boosted.has(c.id)), | |
| 58 | 59 | ...shared, |
| 59 | 60 | ] |
| 60 | 61 | // The chips: the one filter. An ask was filtered by it in `askLinesFor`. |
src/feed/items.ts +3 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import type { Chips } from "../core/feed_chips.ts"; | |
| 10 | 11 | import { listDecidedIn } from "../core/decisions.ts"; |
| 11 | 12 | import { audienceFilter } from "../core/event_audience.ts"; |
| 12 | 13 | import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts"; |
| @@ -199,12 +200,13 @@ export function candidatesFor( | ||
| 199 | 200 | db: Database, |
| 200 | 201 | viewer: User, |
| 201 | 202 | now: number, |
| 203 | chips?: Chips, | |
| 202 | 204 | ): Candidate[] { |
| 203 | 205 | const events = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW }).filter( |
| 204 | 206 | audienceFilter(db, viewer), |
| 205 | 207 | ); |
| 206 | 208 | const writable = listReposWritableTo(db, viewer, { limit: 200 }); |
| 207 | const asks = askLinesFor(db, viewer); | |
| 209 | const asks = askLinesFor(db, viewer, chips); | |
| 208 | 210 | const decided = listDecidedIn( |
| 209 | 211 | db, |
| 210 | 212 | writable.map((r) => r.id), |
src/http/mcp.ts +36 −6
| @@ -102,7 +102,7 @@ import { | ||
| 102 | 102 | import { plannerDeclines } from "../crew/autonomy.ts"; |
| 103 | 103 | import { checkSummary, listActiveRuns } from "../core/runs.ts"; |
| 104 | 104 | import { scopeAllows } from "../core/tokens.ts"; |
| 105 | import { getUsersByIds, type User } from "../core/users.ts"; | |
| 105 | import { getUserByName, getUsersByIds, type User } from "../core/users.ts"; | |
| 106 | 106 | import { openapi } from "../contract/openapi.ts"; |
| 107 | 107 | import { audit } from "../contract/audit.ts"; |
| 108 | 108 | import { probeApp } from "../contract/probes.ts"; |
| @@ -120,6 +120,7 @@ import { | ||
| 120 | 120 | milestoneData, |
| 121 | 121 | milestonesJson, |
| 122 | 122 | } from "../web/pages/milestones.ts"; |
| 123 | import { KIND_CHIPS, parseChipArgs } from "../core/feed_chips.ts"; | |
| 123 | 124 | import { appsOf, asksAnswer } from "../web/studio/asks.ts"; |
| 124 | 125 | import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts"; |
| 125 | 126 | import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts"; |
| @@ -198,6 +199,29 @@ interface Tool { | ||
| 198 | 199 | run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer; |
| 199 | 200 | } |
| 200 | 201 | |
| 202 | const CHIP_ARGS = { | |
| 203 | hide_kinds: { | |
| 204 | type: "array", | |
| 205 | items: { type: "string", enum: [...KIND_CHIPS] }, | |
| 206 | maxItems: 50, | |
| 207 | description: "Kinds to leave out (reviews, decisions, crew, releases). Given, it replaces your saved filter for this call.", | |
| 208 | }, | |
| 209 | hide_people: { | |
| 210 | type: "array", | |
| 211 | items: { type: "string", maxLength: 39 }, | |
| 212 | maxItems: 50, | |
| 213 | description: "Usernames of people or agents to leave out. Given, it replaces your saved filter for this call.", | |
| 214 | }, | |
| 215 | } as const; | |
| 216 | ||
| 217 | /** The filter a call names, or undefined for the viewer's saved one. */ | |
| 218 | function chipArgs(ctx: Ctx, args: Record<string, unknown>) { | |
| 219 | return parseChipArgs(args, (name) => { | |
Good: organizations are refused and the name is trimmed. The schema's |
||
| 220 | const who = getUserByName(ctx.app.db, name.trim()); | |
| 221 | return who && who.kind !== "org" ? who.id : undefined; | |
| 222 | }); | |
| 223 | } | |
| 224 | ||
| 201 | 225 | const str = (v: unknown): string => (typeof v === "string" ? v.trim() : ""); |
| 202 | 226 | |
| 203 | 227 | const REPO_ARG = { |
| @@ -589,11 +613,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 589 | 613 | { |
| 590 | 614 | name: "asks", |
| 591 | 615 | description: |
| 592 | "What waits on you, most consequential first: each row names the decision, its options and what happens if nobody acts, and why it ranks where it does (security, money, held-up work, then age). Rows of one kind in one app are collapsed with their item ids. Also the forge's own needs (for a site admin) and the last 7 days' asks raised, acted on, dismissed and gone by themselves.", | |
| 593 | inputSchema: { type: "object", properties: {} }, | |
| 616 | "What waits on you, most consequential first: each row names the decision, its options and what happens if nobody acts, and why it ranks where it does (security, money, held-up work, then age). Rows of one kind in one app are collapsed with their item ids. 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. Also the forge's own needs (for a site admin) and the last 7 days' asks raised, acted on, dismissed and gone by themselves.", | |
| 617 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, | |
| 594 | 618 | writes: false, |
| 595 | run(ctx, user) { | |
| 596 | return good(asksAnswer(ctx, user)); | |
| 619 | run(ctx, user, args) { | |
| 620 | const chips = chipArgs(ctx, args); | |
| 621 | if (chips && "error" in chips) return bad("invalid", chips.error); | |
| 622 | return good(asksAnswer(ctx, user, chips)); | |
| 597 | 623 | }, |
| 598 | 624 | }, |
| 599 | 625 | { |
| @@ -1337,16 +1363,19 @@ const TOOLS: readonly Tool[] = [ | ||
| 1337 | 1363 | { |
| 1338 | 1364 | name: "feed", |
| 1339 | 1365 | description: |
| 1340 | "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.", | |
| 1366 | "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.", | |
| 1341 | 1367 | inputSchema: { |
| 1342 | 1368 | type: "object", |
| 1343 | 1369 | properties: { |
| 1344 | 1370 | limit: { type: "integer", minimum: 1, maximum: FEED_LIMIT_MAX }, |
| 1345 | 1371 | cursor: { type: "string" }, |
| 1372 | ...CHIP_ARGS, | |
| 1346 | 1373 | }, |
| 1347 | 1374 | }, |
| 1348 | 1375 | writes: false, |
| 1349 | 1376 | run(ctx, user, args) { |
| 1377 | const chips = chipArgs(ctx, args); | |
| 1378 | if (chips && "error" in chips) return bad("invalid", chips.error); | |
| 1350 | 1379 | const raw = args["limit"]; |
| 1351 | 1380 | const limit = raw === undefined ? FEED_LIMIT_DEFAULT : Number(raw); |
| 1352 | 1381 | if (!Number.isInteger(limit) || limit < 1 || limit > FEED_LIMIT_MAX) |
| @@ -1358,6 +1387,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1358 | 1387 | return good( |
| 1359 | 1388 | readFeed(ctx.app.db, user, { |
| 1360 | 1389 | limit, |
| 1390 | ...(chips === undefined ? {} : { chips }), | |
| 1361 | 1391 | ...(cursor === undefined ? {} : { cursor }), |
| 1362 | 1392 | }), |
| 1363 | 1393 | ); |
src/web/studio/asks.ts +9 −6
| @@ -19,7 +19,7 @@ import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts"; | ||
| 19 | 19 | import { latestDecision, stopStands } from "../../core/decisions.ts"; |
| 20 | 20 | import { getHold, holdsFor } from "../../core/holds.ts"; |
| 21 | 21 | import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts"; |
| 22 | import { chipsOf } from "../../core/feed_chips.ts"; | |
| 22 | import { chipsOf, type Chips } from "../../core/feed_chips.ts"; | |
| 23 | 23 | import { chipHides } from "../../feed/chips.ts"; |
| 24 | 24 | import { askDismissals, mutedRepos } from "../../core/interactions.ts"; |
| 25 | 25 | import { askMeasure, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts"; |
| @@ -605,10 +605,11 @@ function notDismissed<T extends AskCandidate>( | ||
| 605 | 605 | db: Database, |
| 606 | 606 | user: User, |
| 607 | 607 | asks: readonly T[], |
| 608 | given?: Chips, | |
| 608 | 609 | ): T[] { |
| 609 | 610 | const muted = mutedRepos(db, user.id); |
| 610 | 611 | const dismissed = askDismissals(db, user.id); |
| 611 | const chips = chipsOf(db, user.id); | |
| 612 | const chips = given ?? chipsOf(db, user.id); | |
| 612 | 613 | // A change waiting on review is a `pull`; anything else is a decision. |
| 613 | 614 | // The author is the actor the person chip names. |
| 614 | 615 | const chipped = (a: T): boolean => |
| @@ -798,9 +799,10 @@ function waitingOn( | ||
| 798 | 799 | db: Database, |
| 799 | 800 | user: User | undefined, |
| 800 | 801 | repos: readonly Repo[], |
| 802 | chips?: Chips, | |
| 801 | 803 | ): Scored[] { |
| 802 | 804 | if (!user) return []; |
| 803 | return notDismissed(db, user, scoredFor(db, user, repos)); | |
| 805 | return notDismissed(db, user, scoredFor(db, user, repos), chips); | |
| 804 | 806 | } |
| 805 | 807 | |
| 806 | 808 | /** How an ask that is no longer asked ended, when nobody dismissed it. */ |
| @@ -957,6 +959,7 @@ export const askAnchor = (id: string): string => id.replace(/[^a-z0-9-]/g, "-"); | ||
| 957 | 959 | export function askLinesFor( |
| 958 | 960 | db: Database, |
| 959 | 961 | user: User, |
| 962 | chips?: Chips, | |
| 960 | 963 | ): { |
| 961 | 964 | id: string; |
| 962 | 965 | line: string; |
| @@ -979,7 +982,7 @@ export function askLinesFor( | ||
| 979 | 982 | waitingFor: WaitingFor; |
| 980 | 983 | }[] { |
| 981 | 984 | const repos = listReposVisibleTo(db, user, { limit: 200 }); |
| 982 | return askRows(waitingOn(db, user, repos)).map((row) => { | |
| 985 | return askRows(waitingOn(db, user, repos, chips)).map((row) => { | |
| 983 | 986 | const base = `/${row.repo.ownerName}/${row.repo.name}`; |
| 984 | 987 | const about = { |
| 985 | 988 | id: row.id, |
| @@ -1368,10 +1371,10 @@ export function askSentence(count: number): string { | ||
| 1368 | 1371 | * admin, and the week's measure of asks raised against acted on, dismissed |
| 1369 | 1372 | * and gone by themselves. `GET /api/v1/asks` and the MCP's `asks` read this. |
| 1370 | 1373 | */ |
| 1371 | export function asksAnswer(ctx: Ctx, user: User) { | |
| 1374 | export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) { | |
| 1372 | 1375 | const since = new Date(Date.now() - 7 * 86_400_000).toISOString(); |
| 1373 | 1376 | return { |
| 1374 | rows: askLinesFor(ctx.app.db, user), | |
| 1377 | rows: askLinesFor(ctx.app.db, user, chips), | |
| 1375 | 1378 | forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)), |
| 1376 | 1379 | measure: { days: 7, ...askMeasure(ctx.app.db, user.id, since) }, |
| 1377 | 1380 | }; |
test/asks-mute.test.ts +37
| @@ -23,6 +23,7 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | ||
| 23 | 23 | import { createToken } from "../src/core/tokens.ts"; |
| 24 | 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 25 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 26 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 26 | 27 | import { askCountFor, askLinesFor, repoAsks } from "../src/web/studio/asks.ts"; |
| 27 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 28 | 29 | |
| @@ -218,4 +219,40 @@ describe("muting a repository's asks", () => { | ||
| 218 | 219 | setChip(app.db, alice.id, { kind: "person", value: bot.id }, false); |
| 219 | 220 | expect(lines()).toBe(1); |
| 220 | 221 | }); |
| 222 | ||
| 223 | test("MCP asks applies the saved filter, and takes the same filter as arguments", async () => { | |
| 224 | const token = createToken(app.db, alice.id, "ro", "ro").token; | |
| 225 | ||
outdated
This line is indented 6 spaces inside a 4-space block. The blank line after
outdated
This helper is misindented and is a word-for-word copy of the one added in test/feed-chips.test.ts. Indent it like the code around it, and keep one copy in the shared test helpers. |
||
| 226 | const tool = (token: string, name: string, args: unknown = {}) => | |
| 227 | mcpTool(servers.url, token, name, args); | |
| 228 | const bot = getUserByName(app.db, "bot")!; | |
| 229 | const rows = async (args: unknown = {}) => ((await tool(token, "asks", args)) as any).value.rows.length; | |
| 230 | expect(await rows()).toBe(1); | |
| 231 | // Given, the filter replaces the saved one for that call only. | |
| 232 | expect(await rows({ hide_kinds: ["reviews"] })).toBe(0); | |
| 233 | expect(await rows({ hide_people: ["bot"] })).toBe(0); | |
| 234 | expect(await rows({ hide_people: ["bob"], hide_kinds: ["crew"] })).toBe(1); | |
| 235 | expect(await rows({ hide_kinds: [] })).toBe(1); | |
| 236 | // Saved, it applies with no arguments; an empty filter given lifts it. | |
| 237 | setChip(app.db, alice.id, { kind: "person", value: bot.id }, true); | |
| 238 | expect(await rows()).toBe(0); | |
| 239 | expect(await rows({ hide_kinds: [] })).toBe(1); | |
| 240 | setChip(app.db, alice.id, { kind: "person", value: bot.id }, false); | |
| 241 | // Bad filters are refused, not guessed at. | |
| 242 | expect(((await tool(token, "asks", { hide_kinds: ["nope"] })) as any).error).toContain("hide_kinds"); | |
| 243 | expect(((await tool(token, "asks", { hide_people: ["nobody"] })) as any).error).toContain("hide_people"); | |
| 244 | expect(((await tool(token, "asks", { hide_kinds: "reviews" })) as any).error).toContain("lists"); | |
| 245 | // The manifest says so. | |
| 246 | const res = await fetch(`${servers.url}/mcp`, { | |
| 247 | method: "POST", | |
| 248 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 249 | body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), | |
| 250 | }); | |
| 251 | const tools = ((await res.json()) as any).result.tools as any[]; | |
| 252 | for (const name of ["asks", "feed"]) { | |
| 253 | const t = tools.find((x) => x.name === name); | |
| 254 | expect(t.description).toContain("saved filter"); | |
| 255 | expect(Object.keys(t.inputSchema.properties)).toEqual(expect.arrayContaining(["hide_kinds", "hide_people"])); | |
| 256 | } | |
| 257 | }); | |
| 221 | 258 | }); |
test/feed-chips.test.ts +18
| @@ -14,6 +14,7 @@ import { chipsOf, KIND_CHIPS, noChips, setChip } from "../src/core/feed_chips.ts | ||
| 14 | 14 | import type { Repo } from "../src/core/repos.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 17 | 18 | import { chipHides } from "../src/feed/chips.ts"; |
| 18 | 19 | import type { FeedKind } from "../src/feed/items.ts"; |
| 19 | 20 | import { readFeed } from "../src/feed/feed.ts"; |
| @@ -173,4 +174,21 @@ describe("the chips of a forge", () => { | ||
| 173 | 174 | expect(page).toMatch(/data-chip="person"\s+data-value="alice"/); |
| 174 | 175 | setChip(app.db, bob.id, { kind: "kind", value: "crew" }, false); |
| 175 | 176 | }); |
| 177 | ||
| 178 | test("MCP feed applies the saved filter and takes the same filter as arguments", async () => { | |
| 179 | const tool = (token: string, name: string, args: unknown = {}) => | |
outdated
This is the same pasted helper, after a stray blank line and misindented. Remove the duplication (see test/asks-mute.test.ts). |
||
| 180 | mcpTool(servers.url, token, name, args); | |
| 181 | const kindsVia = async (args: unknown = {}) => | |
| 182 | (((await tool(bobToken, "feed", args)) as any).value.items as { kind: string }[]).map((i) => i.kind).sort(); | |
| 183 | expect(await kindsVia()).toEqual(["look", "pull", "release", "run"]); | |
| 184 | expect(await kindsVia({ hide_kinds: ["releases"] })).toEqual(["look", "pull", "run"]); | |
| 185 | expect(await kindsVia({ hide_people: ["carol"] })).toEqual(["look", "release", "run"]); | |
| 186 | // Saved, it applies by default; given, the arguments replace it. | |
| 187 | setChip(app.db, bob.id, { kind: "kind", value: "crew" }, true); | |
| 188 | expect(await kindsVia()).toEqual(["pull", "release", "run"]); | |
| 189 | expect(await kindsVia({ hide_kinds: [] })).toEqual(["look", "pull", "release", "run"]); | |
| 190 | setChip(app.db, bob.id, { kind: "kind", value: "crew" }, false); | |
| 191 | expect(((await tool(bobToken, "feed", { hide_kinds: ["x"] })) as any).error).toContain("hide_kinds"); | |
| 192 | expect(((await tool(bobToken, "feed", { hide_people: [5] })) as any).error).toContain("hide_people"); | |
| 193 | }); | |
| 176 | 194 | }); |
test/helpers/mcp.ts added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | // One MCP tools/call over HTTP, answered as { value } or { error }. | |
| 2 | export const mcpTool = async ( | |
| 3 | base: string, | |
| 4 | token: string, | |
| 5 | name: string, | |
| 6 | args: unknown = {}, | |
| 7 | ): Promise<{ value?: any; error?: string }> => { | |
| 8 | const res = await fetch(`${base}/mcp`, { | |
| 9 | method: "POST", | |
| 10 | headers: { | |
| 11 | "content-type": "application/json", | |
| 12 | accept: "application/json, text/event-stream", | |
| 13 | authorization: `Bearer ${token}`, | |
| 14 | }, | |
| 15 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 16 | }); | |
| 17 | const body = (await res.json()) as any; | |
| 18 | const text: string = body.result.content[0].text; | |
| 19 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 20 | }; |
Low, not blocking: this resolves any username. The distinct error for an unknown name tells the caller whether that account exists. That's fine if usernames are public on the forge. If any are not (for example a sealed or suspended user), resolve only people the viewer can already see.