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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7875-mcp-issues-lists-a-repository-s-issues-b → main 3 commits fork 2 threads · 2 unresolved
5 files
changed +272
−0
docs/MCP.md +1
| @@ -100,6 +100,7 @@ for a tunnel and a forge that sleeps. | ||
| 100 | 100 | | `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 | |
| 101 | 101 | | `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 | | |
| 102 | 102 | | `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 | | | |
| 103 | 104 | | `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 | |
| 104 | 105 | | `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 | |
| 105 | 106 | | `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"; | ||
| 70 | 70 | export { tool as invites } from "./invites.ts"; |
| 71 | 71 | export { tool as issue } from "./issue.ts"; |
| 72 | 72 | export { tool as issue_teams } from "./issue_teams.ts"; |
| 73 | export { tool as issues } from "./issues.ts"; | |
| 73 | 74 | export { tool as labels } from "./labels.ts"; |
| 74 | 75 | export { tool as land_past_gate } from "./land_past_gate.ts"; |
| 75 | 76 | 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); | |
| 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 |
||
| 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", () => { | ||
| 214 | 214 | "seal", |
| 215 | 215 | "secrets", |
| 216 | 216 | "issue_teams", |
| 217 | "issues", | |
| 217 | 218 | "orgs", |
| 218 | 219 | "now", |
| 219 | 220 | "org_create", |
new above
offline
This marking branch is untested. Add a crew-agent case that expects
untrusted: true, and a plain-reader case that expects nountrustedkey.