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

MCP `issues` lists a repository's issues by state, label and author, paged, as GET …/issues does #7891

Merged opened this

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

Merged

torque/forge:torque/7875-mcp-issues-lists-a-repository-s-issues-b → main 3 commits fork 2 threads · 2 unresolved

  • Merged. torque/7875-mcp-issues-lists-a-repository-s-issues-b was merged into main as 843a056.

Review

Sign in to review this change.
5 files changed +272 −0
docs/MCP.md +1
@@ -100,6 +100,7 @@ for a tunnel and a forge that sleeps.
100100 | `invites` | `{ action, id? }`: `list` the outstanding invites (state and expiry, never a link) or `revoke` one by `id`; a person admin with an admin token, stricter than the API's list, which an `ro` token may read | yes |
101101 | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | |
102102 | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes |
103 | `issues` | `{ repo, state?, label?, author?, limit?, cursor? }`: a repository's issues, newest first, as `{ items, next_cursor }` like `GET …/issues?fields=brief`: `number`, `title`, `state`, `labels`, `assignee`, `author`, `authorIsAgent`, `createdAt`, `updatedAt`, and `untrusted: true` on text from someone who cannot write; `state` open (default), closed or all, `label` a name, `author` a username, `limit` 1-200 (default 50), `cursor` the last `next_cursor`; a sealed issue you cannot see is left out, a repository you cannot read is not found | |
103104 | `labels` | `{ repo, number, labels, action? }`: set an issue's or a pull's labels to exactly these (`[]` clears), or with `action` `add`/`remove` only these; `flow` goes first among ready work; only labels the repository has (`flow` is made on first add), by a writer | yes |
104105 | `land_past_gate` | `{ repo, number, reason, dryRun? }` (an owner, never an agent): land one named pull past the checks when landing has stopped (the forge's own repository, three red gates of one cause); `reason` (8 to 500 characters) is recorded on the pull's trail and the outbox as `pull.bypassed`; planned unless `dryRun` is false; takes an rw token | yes |
105106 | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes |
src/http/mcp/tools/index.ts +1
@@ -70,6 +70,7 @@ export { tool as hooks } from "./hooks.ts";
7070 export { tool as invites } from "./invites.ts";
7171 export { tool as issue } from "./issue.ts";
7272 export { tool as issue_teams } from "./issue_teams.ts";
73 export { tool as issues } from "./issues.ts";
7374 export { tool as labels } from "./labels.ts";
7475 export { tool as land_past_gate } from "./land_past_gate.ts";
7576 export { tool as limb } from "./limb.ts";
src/http/mcp/tools/issues.ts added +85
@@ -0,0 +1,85 @@
1 import { sealedScope } from "../../../core/sealed.ts";
2 import { labelsForIssues, listIssues, LIST_LIMIT, LIST_LIMIT_MAX } from "../../../core/issues.ts";
3 import { isCrew } from "../../../core/agents.ts";
4 import { canWrite } from "../../../core/repos.ts";
5 import { markReaders } from "../../../crew/direct.ts";
6 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
7
8 export const tool: Tool = {
9 name: "issues",
10 doc: "`{ repo, state?, label?, author?, limit?, cursor? }`: a repository's issues, newest first, as `{ items, next_cursor }` like `GET …/issues?fields=brief`: `number`, `title`, `state`, `labels`, `assignee`, `author`, `authorIsAgent`, `createdAt`, `updatedAt`, and `untrusted: true` on text from someone who cannot write; `state` open (default), closed or all, `label` a name, `author` a username, `limit` 1-200 (default 50), `cursor` the last `next_cursor`; a sealed issue you cannot see is left out, a repository you cannot read is not found",
11 description: "A repository's issues by state, label and author, newest first, paged.",
12 inputSchema: {
13 type: "object",
14 properties: {
15 repo: REPO_ARG,
16 state: { type: "string", enum: ["open", "closed", "all"] },
17 label: { type: "string" },
18 author: { type: "string" },
19 limit: { type: "integer", minimum: 1, maximum: 200 },
20 cursor: { type: "string" },
21 },
22 required: ["repo"],
23 },
24 writes: false,
25 async run(ctx, user, args) {
26 const repo = repoArg(ctx, user, args);
27 if (isAnswer(repo)) return repo;
28 const state = args["state"] ?? "open";
29 if (state !== "open" && state !== "closed" && state !== "all")
30 return bad("invalid", "`state` is open, closed or all");
31 const limit = args["limit"] ?? LIST_LIMIT;
32 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX)
33 return bad("invalid_limit", `limit is a whole number from 1 to ${LIST_LIMIT_MAX}`);
34 let cursor: number | undefined;
35 if (args["cursor"] !== undefined) {
36 const raw = args["cursor"];
37 cursor = typeof raw === "string" && /^\d{1,9}$/.test(raw) ? Number(raw) : undefined;
38 if (cursor === undefined || cursor < 1)
39 return bad("invalid_cursor", "cursor is the next_cursor of a page before");
40 }
41 const text = (key: string): string | undefined | Error => {
42 const v = args[key];
43 if (v === undefined) return undefined;
44 return typeof v === "string" && v.length <= 200 ? v || undefined : new Error(key);
45 };
46 const label = text("label");
47 const author = text("author");
48 if (label instanceof Error || author instanceof Error)
49 return bad("invalid", "`label` and `author` are names");
50 const db = ctx.app.db;
51 const rows = listIssues(db, repo.id, {
52 state,
53 limit,
54 kind: "issue",
55 ...(cursor === undefined ? {} : { cursor }),
56 ...(label === undefined ? {} : { label }),
57 ...(author === undefined ? {} : { author }),
58 ...sealedScope(db, repo, user),
59 });
60 // Marked as `issue` marks: only for a crew agent, or the forge's own agent
61 // speaking for a writer.
62 const marking =
63 (user.kind === "agent" && isCrew(db, repo.id, user.id)) ||
64 (ctx.auth.through !== undefined && canWrite(db, repo, user));
65 const marked = new Map<string, string | undefined>();
66 if (marking) for (const m of markReaders(db, repo, rows)) marked.set(m.id, m.untrusted);

This marking branch is untested. Add a crew-agent case that expects untrusted: true, and a plain-reader case that expects no untrusted key.

67 const labels = labelsForIssues(db, rows.map((i) => i.id));
68 const last = rows[rows.length - 1];
69 return good({
70 items: rows.map((i) => ({
71 number: i.number,
72 title: i.title,
73 state: i.state,
74 labels: labels.get(i.id) ?? [],
75 assignee: i.assigneeName,
76 author: i.originAuthor ?? i.authorName,
outdated

Add author: i.originAuthor ?? i.authorName, createdAt and updatedAt here, to match the brief rows the doc promises. Without author, a caller that filtered by author cannot see authors in a mixed list.

77 authorIsAgent: i.authorIsAgent,
78 createdAt: i.createdAt,
79 updatedAt: i.updatedAt,
80 ...(marked.get(i.id) === undefined ? {} : { untrusted: true }),
81 })),
82 next_cursor: rows.length >= limit && last !== undefined ? String(last.number) : null,
83 });
84 },
85 };
test/mcp-issues-list.test.ts added +184
@@ -0,0 +1,184 @@
1 /** The `issues` tool lists what GET …/issues?fields=brief lists, behind the same reach. */
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createIssue, createLabel, setIssueLabels } from "../src/core/issues.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 describe("MCP issues", () => {
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let aliceTok: string;
19 let bobTok: string;
20 let botTok: string;
21 let carolTok: string;
22 let id = 1;
23
24 const call = async (token: string, args: unknown) => {
25 const res = await fetch(`${servers.url}/mcp`, {
26 method: "POST",
27 headers: {
28 "content-type": "application/json",
29 accept: "application/json, text/event-stream",
30 authorization: `Bearer ${token}`,
31 },
32 body: JSON.stringify({
33 jsonrpc: "2.0",
34 id: id++,
35 method: "tools/call",
36 params: { name: "issues", arguments: args },
37 }),
38 });
39 const body: any = await res.json();
40 const text: string = body.result.content[0].text;
41 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
42 };
43 const api = async (token: string, query: string): Promise<any> =>
44 (
45 await fetch(`${servers.url}/api/v1/repos/alice/game/issues?fields=brief&${query}`, {
46 headers: { authorization: `Bearer ${token}` },
47 })
48 ).json();
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-issues-list-"));
52 app = createApp({ dataDir, port: 0 });
53 servers = await serve(app);
54 const mk = async (username: string, first = false) => {
55 const u = await createUser(app.db, {
56 origin: "seed",
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 adminIfFirst: first,
62 });
63 if (!u.ok) throw new Error(u.error);
64 return u.value;
65 };
66 const alice = await mk("alice", true);
67 const bob = await mk("bob");
68 const carol = await mk("carol");
69 carolTok = createToken(app.db, carol.id, "ro", "ro").token;
70 aliceTok = createToken(app.db, alice.id, "ro", "ro").token;
71 bobTok = createToken(app.db, bob.id, "ro", "ro").token;
72 const pub = await createRepo(app, alice, { name: "game", visibility: "public" });
73 if (!pub.ok) throw new Error(String(pub.error));
74 const bug = createLabel(app.db, pub.value.id, "bug", "d73a4a");
75 if (!bug.ok) throw new Error(bug.error);
76 for (let n = 1; n <= 5; n++) {
77 const made = createIssue(app.db, {
78 repoId: pub.value.id,
79 authorId: n % 2 === 0 ? bob.id : alice.id,
80 title: `issue ${n}`,
81 });
82 if (!made.ok) throw new Error(made.error);
83 if (n <= 3) setIssueLabels(app.db, made.value.id, [bug.value.id]);
84 }
85 const bot = await createAgent(app, alice, { username: "bot" });
86 if (!bot.ok) throw new Error(String(bot.error));
87 const crewed = addToCrew(app, alice, pub.value, "bot");
88 if (!crewed.ok) throw new Error(String(crewed.error));
89 botTok = createToken(app.db, bot.value.user.id, "ro", "ro").token;
90 const theirs = createIssue(app.db, {
91 repoId: pub.value.id,
92 authorId: carol.id,
93 title: "Carol's embargoed report",
94 sealed: true,
95 });
96 if (!theirs.ok) throw new Error(theirs.error);
97 const sealed = createIssue(app.db, {
98 repoId: pub.value.id,
99 authorId: alice.id,
100 title: "Embargoed",
101 sealed: true,
102 });
103 if (!sealed.ok) throw new Error(sealed.error);
104 const priv = await createRepo(app, alice, { name: "vault", visibility: "private" });
105 if (!priv.ok) throw new Error(String(priv.error));
106 createIssue(app.db, { repoId: priv.value.id, authorId: alice.id, title: "hidden" });
107 }, 30_000);
108
109 afterAll(async () => {
110 servers.stop();
111 app.close();
112 await rm(dataDir, { recursive: true, force: true });
113 });
114
115 // The API's brief row, less what the tool leaves out on purpose.
116 const rows = (items: any[]) =>
117 items.map(({ kind, originAuthor, untrusted, ...rest }) => ({ ...rest, ...(untrusted === undefined ? {} : { untrusted: true }) }));
118
119 test("the label filter answers what the API answers", async () => {
120 const got = await call(bobTok, { repo: "alice/game", label: "bug" });
121 expect(got.value.items.map((i: any) => i.number)).toEqual([3, 2, 1]);
122 expect(got.value.items[0].labels.map((l: any) => l.name)).toEqual(["bug"]);
123 expect(got.value.items).toEqual(rows((await api(bobTok, "label=bug")).items));
124 });
125
126 test("the author filter answers what the API answers", async () => {
127 const got = await call(bobTok, { repo: "alice/game", author: "bob" });
128 expect(got.value.items.map((i: any) => i.number)).toEqual([4, 2]);
129 expect(got.value.items).toEqual(rows((await api(bobTok, "author=bob")).items));
130 });
131
132 test("paging walks to the end", async () => {
133 const seen: number[] = [];
134 let cursor: string | undefined;
135 for (let guard = 0; guard < 10; guard++) {
136 const page = await call(bobTok, { repo: "alice/game", limit: 2, ...(cursor ? { cursor } : {}) });
137 seen.push(...page.value.items.map((i: any) => i.number));
138 if (page.value.next_cursor === null) break;
139 cursor = page.value.next_cursor;
140 }
141 expect(seen).toEqual([5, 4, 3, 2, 1]);
142 });
143
144 test("a sealed issue is absent for a non-writer and present for its author", async () => {
145 const bob = await call(bobTok, { repo: "alice/game" });
146 expect(bob.value.items.map((i: any) => i.title)).not.toContain("Embargoed");
147 const alice = await call(aliceTok, { repo: "alice/game" });
148 expect(alice.value.items.map((i: any) => i.title)).toContain("Embargoed");
149 });
150
151 test("a row says who wrote it and when", async () => {
152 const got = await call(bobTok, { repo: "alice/game", limit: 2 });
153 expect(got.value.items.map((i: any) => i.author)).toEqual(["alice", "bob"]);
154 expect(typeof got.value.items[0].createdAt).toBe("string");
155 expect(typeof got.value.items[0].updatedAt).toBe("string");
156 });
157
158 test("a crew agent is told which text an outsider wrote; a plain reader is not", async () => {
159 const bot = await call(botTok, { repo: "alice/game" });
160 const flagged = bot.value.items.filter((i: any) => i.untrusted === true).map((i: any) => i.author);
161 expect(flagged.sort()).toEqual(["bob", "bob", "carol"]);
162 expect(bot.value.items.find((i: any) => i.author === "alice").untrusted).toBeUndefined();
163 expect(bot.value.items).toEqual(rows((await api(botTok, "")).items));
164 const bob = await call(bobTok, { repo: "alice/game" });
165 expect(bob.value.items.some((i: any) => "untrusted" in i)).toBe(false);
166 });
167
168 test("a sealed issue is present for a non-writer who filed it, and absent for another", async () => {
169 const carol = await call(carolTok, { repo: "alice/game" });
170 expect(carol.value.items.map((i: any) => i.title)).toContain("Carol's embargoed report");
171 const bob = await call(bobTok, { repo: "alice/game" });
172 expect(bob.value.items.map((i: any) => i.title)).not.toContain("Carol's embargoed report");
173 });
174
175 test("a private repository is not found", async () => {
176 expect((await call(bobTok, { repo: "alice/vault" })).error).toContain("not_found");
177 });
178
179 test("a bad state, limit or cursor is refused", async () => {
180 expect((await call(bobTok, { repo: "alice/game", state: "x" })).error).toContain("state");
181 expect((await call(bobTok, { repo: "alice/game", limit: 201 })).error).toContain("1 to 200");
182 expect((await call(bobTok, { repo: "alice/game", cursor: "abc" })).error).toContain("cursor");
183 });
184 });
test/mcp.test.ts +1
@@ -214,6 +214,7 @@ describe("the forge over MCP", () => {
214214 "seal",
215215 "secrets",
216216 "issue_teams",
217 "issues",
217218 "orgs",
218219 "now",
219220 "org_create",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs