Public: anyone can read this; the crew can change it.
A test pins MCP `board_pins`: with no `op` it lists the caller's home pins, and pinning matches the API #7109
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7060-a-test-pins-mcp-board-pins-with-no-op-it → main 1 commit fork 2 threads · 2 unresolved
1 file
changed +107
−0
test/mcp-board-pins.test.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** The `board_pins` tool lists, pins and unpins as GET/POST /api/v1/boards/pins do, and refuses agents and non-admins. */ | |
| 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 { writeBoard } from "../src/actions/boards.ts"; | |
| 8 | import { createAgent } from "../src/actions/crew.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | const panel = (title: string) => ({ title, chart: "line", query: { source: "runs", aggregate: "count", groupBy: "kind" }, w: 1, h: 1 }); | |
| 14 | ||
| 15 | describe("MCP board_pins", () => { | |
| 16 | let dir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | const tok: Record<string, string> = {}; | |
| 20 | ||
| 21 | const tool = async (who: string, args: Record<string, unknown>) => { | |
| 22 | const res = await fetch(`${servers.url}/mcp`, { | |
| 23 | method: "POST", | |
| 24 | headers: { | |
| 25 | "content-type": "application/json", | |
| 26 | accept: "application/json, text/event-stream", | |
| 27 | authorization: `Bearer ${tok[who]}`, | |
| 28 | }, | |
| 29 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "board_pins", arguments: args } }), | |
| 30 | }); | |
| 31 | const body: any = await res.json(); | |
| 32 | return { isError: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 33 | }; | |
| 34 | const api = async (who: string) => { | |
| 35 | const res = await fetch(`${servers.url}/api/v1/boards/pins`, { headers: { authorization: `Bearer ${tok[who]}` } }); | |
| 36 | return { status: res.status, body: res.status === 200 ? ((await res.json()) as any) : undefined }; | |
| 37 | }; | |
| 38 | const apiPairs = async () => (await api("alice")).body.pins.map((p: any) => ({ slug: p.slug, title: p.title })); | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dir = await mkdtemp(join(tmpdir(), "forge-mcp-board-pins-")); | |
| 42 | app = createApp({ dataDir: dir, port: 0 }); | |
| 43 | servers = await serve(app); | |
| 44 | const mk = async (username: string, adminIfFirst?: boolean): Promise<User> => { | |
| 45 | const r = await createUser(app.db, { | |
| 46 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 47 | password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}), | |
| 48 | }); | |
| 49 | if (!r.ok) throw new Error(r.error); | |
| 50 | return r.value; | |
| 51 | }; | |
| 52 | const alice = await mk("alice", true); | |
| 53 | const bob = await mk("bob"); | |
| 54 | const made = await createAgent(app, alice, { username: "scout", brief: "b", model: "m" }); | |
| 55 | if (!made.ok) throw new Error(String(made.error)); | |
| 56 | app.db.run("UPDATE user SET is_admin = 1 WHERE id = ?", [made.value.user.id]); | |
| 57 | tok.alice = createToken(app.db, alice.id, "t", "admin").token; | |
| 58 | tok.bob = createToken(app.db, bob.id, "t", "admin").token; | |
| 59 | tok.scout = createToken(app.db, made.value.user.id, "t", "admin").token; | |
| 60 | const w = await writeBoard(app, alice, "mine", { title: "Mine", range: "all", refresh: "off", panels: [panel("Pin A"), panel("Pin B")] }); | |
| 61 | if (!w.ok) throw new Error(JSON.stringify(w.error)); | |
| 62 | }, 30_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | servers.stop(); | |
| 66 | app.close(); | |
| 67 | await rm(dir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("with no op a person admin lists the same pins as the API, and pin and unpin show there", async () => { | |
| 71 | let r = await tool("alice", {}); | |
| 72 | expect(r.isError).toBe(false); | |
| 73 | expect(JSON.parse(r.text)).toEqual({ pins: [] }); | |
| 74 | expect(await apiPairs()).toEqual([]); | |
| 75 | ||
| 76 | r = await tool("alice", { op: "pin", slug: "mine", title: "Pin A" }); | |
| 77 | expect(r.isError).toBe(false); | |
| 78 | await tool("alice", { op: "pin", slug: "mine", title: "Pin B" }); | |
| 79 | const listed = JSON.parse((await tool("alice", {})).text); | |
| 80 | expect(listed.pins).toEqual([{ slug: "mine", title: "Pin A" }, { slug: "mine", title: "Pin B" }]); | |
| 81 | expect(await apiPairs()).toEqual(listed.pins); | |
| 82 | ||
| 83 | r = await tool("alice", { op: "unpin", slug: "mine", title: "Pin A" }); | |
| 84 | expect(r.isError).toBe(false); | |
| 85 | expect(await apiPairs()).toEqual([{ slug: "mine", title: "Pin B" }]); | |
| 86 | expect(JSON.parse((await tool("alice", {})).text).pins).toEqual(await apiPairs()); | |
| 87 | }); | |
| 88 | ||
| 89 | test("pinning a panel that does not exist is refused and the list is unchanged", async () => { | |
| 90 | const before = await apiPairs(); | |
| 91 | for (const args of [{ slug: "mine", title: "Nope" }, { slug: "ghost", title: "Pin A" }]) { | |
| 92 | const r = await tool("alice", { op: "pin", ...args }); | |
| 93 | expect(r.isError).toBe(true); | |
| 94 | } | |
| 95 | expect(await apiPairs()).toEqual(before); | |
| 96 | expect(JSON.parse((await tool("alice", {})).text).pins).toEqual(before); | |
| 97 | }); | |
Only isError is checked here. Also assert the error text (or code) so a refusal for the wrong reason would fail. |
||
| 98 | ||
| 99 | test("an agent and a non-admin are refused, and pin nothing", async () => { | |
| 100 | for (const who of ["scout", "bob"]) { | |
| 101 | expect((await tool(who, {})).isError).toBe(true); | |
| 102 | expect((await tool(who, { op: "pin", slug: "mine", title: "Pin A" })).isError).toBe(true); | |
| 103 | } | |
| 104 | expect((await api("scout")).status).toBe(403); | |
| 105 | expect(await apiPairs()).toEqual([{ slug: "mine", title: "Pin B" }]); | |
| 106 | }); | |
| 107 | }); |
new above
offline
The tool also supports op=move. Add one pin-then-move case compared against the API order.