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

A test pins MCP `asked`: `show` and `order` take only their known values, and a repository the caller cannot read lists nothing #7180

Merged opened this

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

Merged

scout/forge:scout/7168-a-test-pins-mcp-asked-show-and-order-tak → main 1 commit fork

  • Merged. scout/7168-a-test-pins-mcp-asked-show-and-order-tak was merged into main as 59448b4.

Review

Sign in to review this change.
1 file changed +127 −0
test/mcp-asked-args.test.ts added +127
@@ -0,0 +1,127 @@
1 /** The `asked` tool takes only the known `show` and `order` words, and lists nothing in a repository the caller cannot write. */
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 { createRepo } from "../src/actions/repos.ts";
8 import { askedIn, askedRepos, askedJson } from "../src/core/asked.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 const DAY = 24 * 60 * 60 * 1000;
15
16 describe("MCP asked arguments", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let alice: User;
21 let aliceTok: string;
22 let bobTok: string;
23 let id = 1;
24
25 const call = async (token: string, args: unknown = {}) => {
26 const res = await fetch(`${servers.url}/mcp`, {
27 method: "POST",
28 headers: {
29 "content-type": "application/json",
30 accept: "application/json, text/event-stream",
31 authorization: `Bearer ${token}`,
32 },
33 body: JSON.stringify({
34 jsonrpc: "2.0",
35 id: id++,
36 method: "tools/call",
37 params: { name: "asked", arguments: args },
38 }),
39 });
40 const body: any = await res.json();
41 const text: string = body.result.content[0].text;
42 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
43 };
44
45 /** What the /asked page reads for these choices. */
46 const page = (show: "all" | "unshipped" | "flagged", order: "oldest" | "newest") =>
47 askedIn(app.db, askedRepos(app.db, alice, null), { show, order }).map(askedJson);
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-asked-args-"));
51 app = createApp({ dataDir, port: 0 });
52 servers = await serve(app);
53 const mk = async (username: string, first = false) => {
54 const u = await createUser(app.db, {
55 origin: "seed",
56 trusted: true,
57 username,
58 email: `${username}@forge.local`,
59 password: "a-good-passphrase",
60 adminIfFirst: first,
61 });
62 if (!u.ok) throw new Error(u.error);
63 return u.value;
64 };
65 alice = await mk("alice", true);
66 const bob = await mk("bob");
67 aliceTok = createToken(app.db, alice.id, "ro", "ro").token;
68 bobTok = createToken(app.db, bob.id, "ro", "ro").token;
69 for (const [name, visibility] of [["open", "public"], ["secret", "private"]] as const) {
70 const made = await createRepo(app, alice, { name, visibility });
71 if (!made.ok) throw new Error(String(made.error));
72 for (const [n, days] of [["first", 9], ["second", 5], ["third", 1]] as const) {
73 const issue = createIssue(app.db, { repoId: made.value.id, authorId: alice.id, title: `${n} in ${name}` });
74 if (!issue.ok) throw new Error(issue.error);
75 app.db
76 .query("UPDATE issue SET created_at = ?1 WHERE id = ?2")
77 .run(new Date(Date.now() - days * DAY).toISOString(), issue.value.id);
78 }
79 }
80 // One ask is done with, so "all" has more than "unshipped".
81 app.db.query("UPDATE issue SET state = 'closed' WHERE title = 'second in open'").run();
82 }, 30_000);
83
84 afterAll(async () => {
85 servers.stop();
86 app.close();
87 await rm(dataDir, { recursive: true, force: true });
88 });
89
90 test("no arguments lists unshipped asks, oldest first", async () => {
91 const got = await call(aliceTok);
92 const titles = got.value.asked.map((a: any) => a.title);
93 expect(titles.length).toBeGreaterThan(0);
94 expect(got.value.asked).toEqual(page("unshipped", "oldest"));
95 const times: string[] = got.value.asked.map((a: any) => a.askedAt);
96 expect(times).toEqual([...times].sort());
97 expect(titles).not.toContain("second in open");
98 });
99
100 test("each known show and order returns what the page shows", async () => {
101 for (const show of ["unshipped", "flagged", "all"] as const)
102 for (const order of ["oldest", "newest"] as const)
103 expect((await call(aliceTok, { show, order })).value.asked).toEqual(page(show, order));
104 const all = (await call(aliceTok, { show: "all" })).value.asked;
105 expect(all.map((a: any) => a.title)).toContain("second in open");
106 const newest = (await call(aliceTok, { order: "newest" })).value.asked;
107 expect(newest.map((a: any) => a.title)).toEqual(
108 [...(await call(aliceTok)).value.asked].reverse().map((a: any) => a.title),
109 );
110 });
111
112 test("an unknown show or order is refused", async () => {
113 expect((await call(aliceTok, { show: "everything" })).error).toContain("invalid_show");
114 expect((await call(aliceTok, { order: "random" })).error).toContain("invalid_order");
115 expect((await call(aliceTok, { show: "all", order: "random" })).error).toContain("invalid_order");
116 });
117
118 test("a repository the caller cannot read lists nothing about it", async () => {
119 const mine = await call(aliceTok, { repo: "alice/secret" });
120 expect(mine.value.asked.length).toBe(3);
121 const theirs = await call(bobTok, { repo: "alice/secret" });
122 expect(theirs.error).toContain("not_found");
123 expect(JSON.stringify(theirs)).not.toContain("in secret");
124 const none = await call(bobTok);
125 expect(none.value.asked).toEqual([]);
126 });
127 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs