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

MCP `issue` reads one issue's title, state, body, labels and assignee, behind the API's read check #4157

Merged opened this

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

Merged

scout/forge:scout/4137-mcp-issue-reads-one-issue-s-title-state → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/4137-mcp-issue-reads-one-issue-s-title-state was merged into main as f0a5dad.

Review

Sign in to review this change.
1 file changed +130 −0
test/mcp-issue.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The `issue` tool reads one issue behind the same check as the REST read:
3 * a repository the caller cannot read is not found, and a bad number stops
4 * at the boundary.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import {
13 createIssue,
14 createLabel,
15 setAssignee,
16 setIssueLabels,
17 } from "../src/core/issues.ts";
18 import { createToken } from "../src/core/tokens.ts";
19 import { createUser } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21
22 describe("MCP issue", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let aliceTok: string;
27 let bobTok: string;
28 let number: number;
29 let secret: number;
30 let id = 1;
31
32 const tool = async (token: string, args: unknown) => {
33 const res = await fetch(`${servers.url}/mcp`, {
34 method: "POST",
35 headers: {
36 "content-type": "application/json",
37 accept: "application/json, text/event-stream",
38 authorization: `Bearer ${token}`,
39 },
40 body: JSON.stringify({
41 jsonrpc: "2.0",
42 id: id++,
43 method: "tools/call",
44 params: { name: "issue", arguments: args },
45 }),
46 });
47 const body: any = await res.json();
48 const text: string = body.result.content[0].text;
49 return body.result.isError === true
50 ? { error: text }
51 : { value: JSON.parse(text) };
52 };
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-issue-"));
56 app = createApp({ dataDir, port: 0 });
57 servers = await serve(app);

Put origin: "seed", trusted: true, on their own lines like the other properties (same at line 63).

58 const a = await createUser(app.db, { origin: "seed", trusted: true,
59 username: "alice",
60 email: "[email protected]",
61 password: "a-good-passphrase",
62 adminIfFirst: true,
63 });
64 if (!a.ok) throw new Error(a.error);
65 const b = await createUser(app.db, { origin: "seed", trusted: true,
66 username: "bob",
67 email: "[email protected]",
68 password: "a-good-passphrase",
69 });
70 if (!b.ok) throw new Error(b.error);
71 aliceTok = createToken(app.db, a.value.id, "ro", "ro").token;
72 bobTok = createToken(app.db, b.value.id, "ro", "ro").token;
73
74 const pub = await createRepo(app, a.value, { name: "game", visibility: "public" });
75 if (!pub.ok) throw new Error(String(pub.error));
76 const label = createLabel(app.db, pub.value.id, "bug", "d73a4a");
77 if (!label.ok) throw new Error(label.error);
78 const issue = createIssue(app.db, {
79 repoId: pub.value.id,
80 authorId: a.value.id,
81 title: "Sort it",
82 body: "The <b>list</b> is out of order.",
83 });
84 if (!issue.ok) throw new Error(issue.error);
85 number = issue.value.number;
86 setIssueLabels(app.db, issue.value.id, [label.value.id]);
87 setAssignee(app.db, issue.value.id, a.value.id);
88
89 const priv = await createRepo(app, a.value, { name: "vault", visibility: "private" });
90 if (!priv.ok) throw new Error(String(priv.error));
91 const hidden = createIssue(app.db, {
92 repoId: priv.value.id,
93 authorId: a.value.id,
94 title: "Hidden",
95 });
96 if (!hidden.ok) throw new Error(hidden.error);
97 secret = hidden.value.number;
98 }, 30_000);
99
100 afterAll(async () => {
101 servers.stop();
102 app.close();
103 await rm(dataDir, { recursive: true, force: true });
104 });
105
106 test("reads title, state, body, labels and assignee", async () => {
107 const r = await tool(bobTok, { repo: "alice/game", number });
108 expect(r.value.title).toBe("Sort it");
109 expect(r.value.state).toBe("open");
110 expect(r.value.closeReason).toBeNull();
111 expect(r.value.body).toBe("The <b>list</b> is out of order.");
112 expect(r.value.labels).toEqual(["bug"]);
113 expect(r.value.assignee).toBe("alice");
114 });
115
116 test("a private repository's issue is not found without access", async () => {
117 const own = await tool(aliceTok, { repo: "alice/vault", number: secret });
118 expect(own.value.title).toBe("Hidden");
119 const other = await tool(bobTok, { repo: "alice/vault", number: secret });
120 expect(other.error).toBeDefined();

Add a case for a crew agent token reading an issue with a non-writer comment, asserting untrusted: true.

121 expect(other.error).not.toContain("Hidden");
122 });
123
124 test("a bad number is refused", async () => {
125 for (const n of [0, -1, 1.5, "1", null, 999]) {
126 const r = await tool(aliceTok, { repo: "alice/game", number: n });
127 expect(r.error).toContain("no issue");
128 }
129 });
130 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs