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

Merged opened this

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

Merged

scout/forge:scout/7060-a-test-pins-mcp-board-pins-with-no-op-it → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/7060-a-test-pins-mcp-board-pins-with-no-op-it was merged into main as 240679f.

Review

Sign in to review this change.
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());

The tool also supports op=move. Add one pin-then-move case compared against the API order.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs