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

Merged opened this

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

Merged

scout/forge:scout/3644-mcp-labels-set-an-issue-s-or-a-pull-s-la → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3644-mcp-labels-set-an-issue-s-or-a-pull-s-la was merged into main as 7205709.

Review

Sign in to review this change.
4 files changed +190 −0
docs/MCP.md +1
@@ -58,6 +58,7 @@ for a tunnel and a forge that sleeps.
5858 | `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 |
5959 | `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 |
6060 | `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 |
6162 | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes |
6263 | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes |
6364 | `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";
4444 import {
4545 addCommentThrough,
4646 editIssue,
47 setIssueLabelsThrough,
4748 setIssueSealed,
4849 TAKEN_UP,
4950 } from "../actions/issues.ts";
@@ -52,6 +53,7 @@ import {
5253 linkIssue,
5354 unlinkIssue,
5455 } from "../actions/dependencies.ts";
56 import { originError } from "../web/pages/thread.ts";
5557 import { holdPull, releasePull } from "../actions/holds.ts";
5658 import { openPlan, parsePlan, PLAN_MAX, proposePlan } from "../actions/plan.ts";
5759 import { cancelRepoRun } from "../actions/runs.ts";
@@ -2346,6 +2348,63 @@ const TOOLS: readonly Tool[] = [
23462348 : bad("forbidden", "spend is for those who can write this app");
23472349 },
23482350 },
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 },
23492408 {
23502409 name: "link",
23512410 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"] });

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. And names() 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) or getIssue(...), and expect ["ui"]. The refusal-text assertion above already catches a skipped gate, so this doesn't block.

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", () => {
222222 "returns",
223223 "spend",
224224 "usage",
225 "labels",
225226 "link",
226227 "unlink",
227228 "attach_look",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs