Public: anyone can read this; the crew can change it.
MCP `labels`: set an issue's or a pull's labels, with the same rules as the page #3655
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3644-mcp-labels-set-an-issue-s-or-a-pull-s-la → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +190
−0
docs/MCP.md +1
| @@ -58,6 +58,7 @@ for a tunnel and a forge that sleeps. | ||
| 58 | 58 | | `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 | |
| 59 | 59 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| 60 | 60 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | |
| 61 | | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes | | |
| 61 | 62 | | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes | |
| 62 | 63 | | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes | |
| 63 | 64 | | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes | |
src/http/mcp.ts +59
| @@ -44,6 +44,7 @@ import { watchMilestone } from "../actions/watches.ts"; | ||
| 44 | 44 | import { |
| 45 | 45 | addCommentThrough, |
| 46 | 46 | editIssue, |
| 47 | setIssueLabelsThrough, | |
| 47 | 48 | setIssueSealed, |
| 48 | 49 | TAKEN_UP, |
| 49 | 50 | } from "../actions/issues.ts"; |
| @@ -52,6 +53,7 @@ import { | ||
| 52 | 53 | linkIssue, |
| 53 | 54 | unlinkIssue, |
| 54 | 55 | } from "../actions/dependencies.ts"; |
| 56 | import { originError } from "../web/pages/thread.ts"; | |
| 55 | 57 | import { holdPull, releasePull } from "../actions/holds.ts"; |
| 56 | 58 | import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts"; |
| 57 | 59 | import { cancelRepoRun } from "../actions/runs.ts"; |
| @@ -2346,6 +2348,63 @@ const TOOLS: readonly Tool[] = [ | ||
| 2346 | 2348 | : bad("forbidden", "spend is for those who can write this app"); |
| 2347 | 2349 | }, |
| 2348 | 2350 | }, |
| 2351 | { | |
| 2352 | name: "labels", | |
| 2353 | description: | |
| 2354 | "Set the labels of an issue or a pull to exactly these (none clears them): only labels the repository has, by a person or agent who can write it.", | |
| 2355 | inputSchema: { | |
| 2356 | type: "object", | |
| 2357 | properties: { | |
| 2358 | repo: REPO_ARG, | |
| 2359 | number: { type: "integer", minimum: 1 }, | |
| 2360 | labels: { | |
| 2361 | type: "array", | |
| 2362 | items: { type: "string", maxLength: 64 }, | |
| 2363 | maxItems: 50, | |
| 2364 | }, | |
| 2365 | }, | |
| 2366 | required: ["repo", "number", "labels"], | |
| 2367 | }, | |
| 2368 | writes: true, | |
| 2369 | async run(ctx, user, args) { | |
| 2370 | const repo = repoArg(ctx, user, args); | |
| 2371 | if (isAnswer(repo)) return repo; | |
| 2372 | const chosen = args["labels"]; | |
| 2373 | if ( | |
| 2374 | !Array.isArray(chosen) || | |
| 2375 | chosen.length > 50 || | |
| 2376 | !chosen.every((l) => typeof l === "string" && l.length <= 64) | |
| 2377 | ) | |
| 2378 | return bad("invalid_labels", "labels is a list of up to 50 label names"); | |
| 2379 | const issue = issueArg(ctx, repo, args, "number"); | |
| 2380 | if ("ok" in issue) return issue; | |
| 2381 | // The permission is the action's to check, as on the page. | |
| 2382 | const applied = await setIssueLabelsThrough( | |
| 2383 | ctx.app, | |
| 2384 | user, | |
| 2385 | repo, | |
| 2386 | issue, | |
| 2387 | chosen, | |
| 2388 | ); | |
| 2389 | if (!applied.ok) | |
| 2390 | return bad( | |
| 2391 | applied.error, | |
| 2392 | originError(applied.error) ?? | |
| 2393 | (applied.error === "unknown_label" | |
| 2394 | ? "One of those labels no longer exists." | |
| 2395 | : "You need write access to change labels."), | |
| 2396 | ); | |
| 2397 | const names = applied.value.map((l) => l.name); | |
| 2398 | return good({ | |
| 2399 | says: | |
| 2400 | names.length === 0 | |
| 2401 | ? `Cleared the labels of #${issue.number}.` | |
| 2402 | : `Labels of #${issue.number} set to ${names.join(", ")}.`, | |
| 2403 | labels: names, | |
| 2404 | url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/${issue.kind === "pull" ? "pulls" : "issues"}/${issue.number}`, | |
| 2405 | }); | |
| 2406 | }, | |
| 2407 | }, | |
| 2349 | 2408 | { |
| 2350 | 2409 | name: "link", |
| 2351 | 2410 | description: |
test/mcp-labels.test.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * The `labels` tool is the page's labels form over MCP: the same action, so | |
| 3 | * the same gate. A writer sets them, a reader is told what the page says, | |
| 4 | * and bad input stops 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 { createIssue, createLabel, listLabels } from "../src/core/issues.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | ||
| 18 | describe("MCP labels", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let alice: User; | |
| 23 | let aliceRw: string; | |
| 24 | let bobRw: string; | |
| 25 | let number: number; | |
| 26 | let id = 1; | |
| 27 | ||
| 28 | const tool = async (token: string, args: unknown) => { | |
| 29 | const res = await fetch(`${servers.url}/mcp`, { | |
| 30 | method: "POST", | |
| 31 | headers: { | |
| 32 | "content-type": "application/json", | |
| 33 | accept: "application/json, text/event-stream", | |
| 34 | authorization: `Bearer ${token}`, | |
| 35 | }, | |
| 36 | body: JSON.stringify({ | |
| 37 | jsonrpc: "2.0", | |
| 38 | id: id++, | |
| 39 | method: "tools/call", | |
| 40 | params: { name: "labels", arguments: args }, | |
| 41 | }), | |
| 42 | }); | |
| 43 | const body: any = await res.json(); | |
| 44 | const text: string = body.result.content[0].text; | |
| 45 | return body.result.isError === true | |
| 46 | ? { error: text } | |
| 47 | : { value: JSON.parse(text) }; | |
| 48 | }; | |
| 49 | const names = () => | |
| 50 | listLabels(app.db, repoId) | |
| 51 | .map((l) => l.name) | |
| 52 | .sort(); | |
| 53 | let repoId: string; | |
| 54 | ||
| 55 | beforeAll(async () => { | |
| 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-labels-")); | |
| 57 | app = createApp({ dataDir, port: 0 }); | |
| 58 | servers = await serve(app); | |
| 59 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 60 | username: "alice", | |
| 61 | email: "[email protected]", | |
| 62 | password: "a-good-passphrase", | |
| 63 | adminIfFirst: true, | |
| 64 | }); | |
| 65 | if (!a.ok) throw new Error(a.error); | |
| 66 | alice = a.value; | |
| 67 | const b = await createUser(app.db, { origin: "seed", trusted: true, | |
| 68 | username: "bob", | |
| 69 | email: "[email protected]", | |
| 70 | password: "a-good-passphrase", | |
| 71 | }); | |
| 72 | if (!b.ok) throw new Error(b.error); | |
| 73 | promote(app.db, b.value); | |
| 74 | aliceRw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 75 | bobRw = createToken(app.db, b.value.id, "rw", "rw").token; | |
| 76 | const made = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | repoId = made.value.id; | |
| 79 | createLabel(app.db, repoId, "bug", "d73a4a"); | |
| 80 | createLabel(app.db, repoId, "ui", "0075ca"); | |
| 81 | const issue = createIssue(app.db, { repoId, authorId: alice.id, title: "Sort it" }); | |
| 82 | if (!issue.ok) throw new Error(issue.error); | |
| 83 | number = issue.value.number; | |
| 84 | }, 30_000); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | servers.stop(); | |
| 88 | app.close(); | |
| 89 | await rm(dataDir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | const issueLabels = (token = aliceRw) => | |
| 93 | tool(token, { repo: "alice/game", number, labels: [] }); | |
| 94 | ||
| 95 | test("a writer sets and clears them, and the answer is brief with the URL", async () => { | |
| 96 | const set = await tool(aliceRw, { repo: "alice/game", number, labels: ["bug", "ui"] }); | |
| 97 | expect(set.value.labels.sort()).toEqual(["bug", "ui"]); | |
| 98 | expect(set.value.says).toContain(`#${number}`); | |
| 99 | expect(set.value.url).toEndWith(`/alice/game/issues/${number}`); | |
| 100 | const one = await tool(aliceRw, { repo: "alice/game", number, labels: ["ui"] }); | |
| 101 | expect(one.value.labels).toEqual(["ui"]); | |
| 102 | const cleared = await issueLabels(); | |
| 103 | expect(cleared.value.labels).toEqual([]); | |
| 104 | expect(cleared.value.says).toContain("Cleared"); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a label the repository does not have is refused with the page's words", async () => { | |
| 108 | const r = await tool(aliceRw, { repo: "alice/game", number, labels: ["nope"] }); | |
| 109 | expect(r.error).toContain("One of those labels no longer exists."); | |
| 110 | }); | |
| 111 | ||
| 112 | test("a reader is refused with the page's words and nothing changes", async () => { | |
| 113 | await tool(aliceRw, { repo: "alice/game", number, labels: ["ui"] }); | |
| 114 | const r = await tool(bobRw, { repo: "alice/game", number, labels: ["bug"] }); | |
| 115 | expect(r.error).toContain("You need write access to change labels."); | |
| 116 | const after = await tool(aliceRw, { repo: "alice/game", number, labels: ["ui"] }); | |
| 117 | expect(after.value.labels).toEqual(["ui"]); | |
| 118 | expect(names()).toEqual(["bug", "ui"]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("bad input stops at the boundary", async () => { | |
| 122 | for (const labels of ["bug", [1], [{}], null, Array(51).fill("a"), ["x".repeat(65)]]) { | |
| 123 | const r = await tool(aliceRw, { repo: "alice/game", number, labels }); | |
| 124 | expect(r.error).toContain("labels is a list"); | |
| 125 | } | |
| 126 | expect((await tool(aliceRw, { repo: "alice/game", number: 999, labels: [] })).error).toContain("no issue #999"); | |
| 127 | expect((await tool(aliceRw, { repo: "alice/game", number: "1", labels: [] })).error).toContain("no issue"); | |
| 128 | }); | |
| 129 | }); |
test/mcp.test.ts +1
| @@ -222,6 +222,7 @@ describe("the forge over MCP", () => { | ||
| 222 | 222 | "returns", |
| 223 | 223 | "spend", |
| 224 | 224 | "usage", |
| 225 | "labels", | |
| 225 | 226 | "link", |
| 226 | 227 | "unlink", |
| 227 | 228 | "attach_look", |
new above
offline
Low: this check can't detect a change. Alice sets
["ui"]here herself, so the result is["ui"]whether or not Bob's call wrote["bug"]first. Andnames()on line 118 lists the repository's labels, not the issue's. Read the issue's labels without writing them instead, e.g.labelsFor(app.db, issueId)orgetIssue(...), and expect["ui"]. The refusal-text assertion above already catches a skipped gate, so this doesn't block.