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

MCP `repo_labels`: list, create and remove a repository's labels #5030

Merged opened this

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

Merged

scout/forge:scout/4976-mcp-repo-labels-list-create-and-remove-a → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/4976-mcp-repo-labels-list-create-and-remove-a was merged into main as 17d29f7.

Review

Sign in to review this change.
4 files changed +208 −9
docs/MCP.md +1
@@ -84,6 +84,7 @@ for a tunnel and a forge that sleeps.
8484 | `threads` | `{ repo, number, anchor?, resolved? }`: a pull's review threads with their comments; with `anchor` (from the list) resolve it (`resolved` true, the default) or reopen it (false), by the API's rules: a writer or someone who commented in the thread | yes |
8585 | `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 | |
8686 | `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 |
87 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default; a read-only token may), create (`create`, `name`, `color` six hex digits) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create and remove need an admin token and a repository admin | yes |
8788 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
8889 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
8990 | `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 +69 −9
@@ -138,6 +138,7 @@ import {
138138 listAssignedTo,
139139 listComments,
140140 listOpenAgentPullsVisibleTo,
141 listLabels,
141142 } from "../core/issues.ts";
142143 import { isCrew, type Agent } from "../core/agents.ts";
143144 import { dependenciesOf } from "../core/dependencies.ts";
@@ -189,7 +190,7 @@ import {
189190 directionNote,
190191 OUTSIDERS_NOTE,
191192 } from "../crew/direct.ts";
192 import { setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts";
193 import { createLabel, deleteLabel, setArchived, setCrewOutsiders, updateRepoSettings, setDecisionsBy, repositoryLimitMessage } from "../actions/repos.ts";
193194 import {
194195 deleteAppEnvVar,
195196 listAppEnv,
@@ -302,6 +303,8 @@ interface Tool {
302303 readonly needsAdminScope?: true;
303304 /** With this argument absent the tool only reads, whatever the token. */
304305 readonly readsWithout?: string;
306 /** A call this accepts only reads, whatever the token. */
307 readonly readsWhen?: (args: Args) => boolean;
305308 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
306309 readonly dryByDefault?: true;
307310 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
@@ -515,9 +518,9 @@ const TOOLS: readonly Tool[] = [
515518 type: "object",
516519 properties: {
517520 name: { type: "string", maxLength: 100 },
518 description: { type: "string", maxLength: 4000, description: "What it should become; the first issue." },
521 description: { type: "string", maxLength: 4000, description: "What it should become: the first issue." },
519522 owner: { type: "string", maxLength: 39 },
520 visibility: { type: "string", enum: ["public", "private"], description: "Private unless you say public." },
523 visibility: { type: "string", enum: ["public", "private"], description: "Private unless public." },
521524 agent: { type: "string", maxLength: 39, description: "Agent for the crew and first issue." },
522525 pick: { type: "boolean", description: "Pick the agent from the text." },
523526 },
@@ -1760,8 +1763,8 @@ const TOOLS: readonly Tool[] = [
17601763 type: "object",
17611764 properties: {
17621765 username: { type: "string" },
1763 dailyUsd: { type: ["number", "null"], description: "0.01–10000; null with perRunUsd null clears." },
1764 perRunUsd: { type: ["number", "null"], description: "0.01–10000; null with dailyUsd null clears." },
1766 dailyUsd: { type: ["number", "null"], description: "0.01–10000; null clears (with perRunUsd null)." },
1767 perRunUsd: { type: ["number", "null"], description: "0.01–10000; null clears (with dailyUsd null)." },
17651768 dryRun: { type: "boolean", description: "false to apply" },
17661769 },
17671770 required: ["username", "dailyUsd", "perRunUsd"],
@@ -3635,6 +3638,62 @@ const TOOLS: readonly Tool[] = [
36353638 });
36363639 },
36373640 },
3641 {
3642 name: "repo_labels",
3643 description:
3644 "Repo labels: list (default), create, remove (admin token).",
3645 inputSchema: {
3646 type: "object",
3647 properties: {
3648 repo: REPO_ARG,
3649 action: { type: "string" },
3650 name: { type: "string" },
3651 color: { type: "string" },
3652 id: { type: "string" },
3653 },
3654 required: ["repo"],
3655 },
3656 writes: true,
3657 // Listing, named or by default, is a read: a read-only token may.
3658 readsWhen: (args) => args["action"] === undefined || args["action"] === "list",
3659 run(ctx, user, args) {
3660 const repo = repoArg(ctx, user, args);
3661 if (isAnswer(repo)) return repo;
3662 const action = args["action"] ?? "list";
3663 if (action === "list") return good({ items: listLabels(ctx.app.db, repo.id) });
3664 if (action !== "create" && action !== "remove")
3665 return bad("invalid", "action is list, create or remove");
3666 // The API asks for an admin token on both routes; so does this.
3667 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
3668 return bad("forbidden", "changing labels needs an admin token");
3669 if (action === "create") {
3670 const name = args["name"];
3671 const color = args["color"] ?? "6b7280";
3672 if (typeof name !== "string" || name.trim() === "" || name.length > 64)
3673 return bad("invalid", "name is one to 64 characters");
3674 if (typeof color !== "string" || !/^[0-9a-fA-F]{6}$/.test(color))
3675 return bad("invalid", "color is six hex digits, like 6b7280");
3676 const made = createLabel(ctx.app, user, repo, name, color);
3677 if (!made.ok)
3678 return bad(
3679 made.error,
3680 made.error === "taken"
3681 ? "A label with that name already exists."
3682 : made.error === "invalid"
3683 ? "A label needs a name and a six-digit hex color."
3684 : "no such app or repository (owner/name)",
3685 );
3686 return good({ says: `Created the label ${made.value.name}.`, label: made.value });
3687 }
3688 const id = args["id"];
3689 if (typeof id !== "string" || id === "" || id.length > 64)
3690 return bad("invalid", "id is the label's id, from list");
3691 const gone = deleteLabel(ctx.app, user, repo, id);
3692 return gone.ok
3693 ? good({ says: "Removed the label.", removed: id })
3694 : bad("not_found", "no such label, or you are not this repository's admin");
3695 },
3696 },
36383697 {
36393698 name: "wait_for",
36403699 description: "Make an issue wait for another; `stop: true` ends it.",
@@ -4283,7 +4342,7 @@ const TOOLS: readonly Tool[] = [
42834342 {
42844343 name: "quotas",
42854344 description:
4286 "Per-account repository and app quotas (number or null; not enforced yet). With `key` and `value` (\"\" clears), plans.",
4345 "Per-account repository and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears), plans.",
42874346 inputSchema: {
42884347 type: "object",
42894348 properties: {
@@ -4380,10 +4439,10 @@ const TOOLS: readonly Tool[] = [
43804439 type: "object",
43814440 properties: {
43824441 slug: { type: "string", maxLength: 40 },
4383 panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel, by place from 0." },
4442 panel: { type: "integer", minimum: 0, maximum: 23, description: "One panel, from 0." },
43844443 from: { type: "string", maxLength: 64 },
43854444 to: { type: "string", maxLength: 64 },
4386 step: { type: "string", maxLength: 16, description: "With panel: seconds per bucket, or a count of s, m, h or d." },
4445 step: { type: "string", maxLength: 16, description: "With panel: seconds per bucket, or a count of s/m/h/d." },
43874446 },
43884447 },
43894448 writes: false,
@@ -4587,7 +4646,8 @@ function rpcOne(
45874646 : {};
45884647 const plansOnly =
45894648 (tool.dryByDefault === true && args["dryRun"] !== false) ||
4590 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined);
4649 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined) ||
4650 tool.readsWhen?.(args) === true;
45914651 if (tool.writes && !plansOnly && !scopeAllows(ctx.auth.scope ?? "ro", "rw"))
45924652 return reply({
45934653 content: [
test/mcp-repo-labels.test.ts added +137
@@ -0,0 +1,137 @@
1 /** `repo_labels` is the labels routes over MCP: the same actions, so the same gates. */
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 { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 describe("MCP repo_labels", () => {
13 let dataDir: string;
14 let app: App;
15 let servers: Servers;
16 let admin: string;
17 let adminRw: string;
18 let bob: string;
19 let ro: string;
20 let n = 1;
21
22 const call = async (token: string, args: unknown) => {
23 const res = await fetch(`${servers.url}/mcp`, {
24 method: "POST",
25 headers: {
26 "content-type": "application/json",
27 accept: "application/json, text/event-stream",
28 authorization: `Bearer ${token}`,
29 },
30 body: JSON.stringify({
31 jsonrpc: "2.0",
32 id: n++,
33 method: "tools/call",
34 params: { name: "repo_labels", arguments: args },
35 }),
36 });
37 const body: any = await res.json();
38 const text: string = body.result.content[0].text;
39 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
40 };
41 const R = "alice/game";
42
43 beforeAll(async () => {
44 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-repo-labels-"));
45 app = createApp({ dataDir, port: 0 });
46 servers = await serve(app);
47 const mk = async (username: string, first = false) => {
48 const u = await createUser(app.db, {
49 origin: "seed", trusted: true, username,
50 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
51 });
52 if (!u.ok) throw new Error(u.error);
53 return u.value;
54 };
55 const alice = await mk("alice", true);
56 const b = await mk("bob");
57 admin = createToken(app.db, alice.id, "a", "admin").token;
58 adminRw = createToken(app.db, alice.id, "rw", "rw").token;
59 ro = createToken(app.db, alice.id, "ro", "ro").token;
60 bob = createToken(app.db, b.id, "b", "admin").token;
61 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
62 if (!made.ok) throw new Error(String(made.error));
63 }, 30_000);
64
65 afterAll(async () => {
66 servers.stop();
67 app.close();
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 test("it is listed with a schema", async () => {
72 const res = await fetch(`${servers.url}/mcp`, {
73 method: "POST",
74 headers: {
75 "content-type": "application/json",
76 accept: "application/json, text/event-stream",
77 authorization: `Bearer ${admin}`,
78 },
79 body: JSON.stringify({ jsonrpc: "2.0", id: 999, method: "tools/list" }),
80 });
81 const body: any = await res.json();
82 const t = body.result.tools.find((x: any) => x.name === "repo_labels");
83 expect(t.inputSchema.required).toEqual(["repo"]);
84 });
85
86 test("an admin creates, lists and removes", async () => {
87 const made = await call(admin, { repo: R, action: "create", name: "bug", color: "D73A4A" });
88 expect(made.value.label.color).toBe("d73a4a");
89 expect(made.value.says).toContain("bug");
90 const listed = await call(admin, { repo: R, action: "list" });
91 expect(listed.value.items.map((l: any) => l.name)).toEqual(["bug"]);
92 const gone = await call(admin, { repo: R, action: "remove", id: made.value.label.id });
93 expect(gone.value.says).toContain("Removed");
94 expect((await call(admin, { repo: R })).value.items).toEqual([]);
95 });
96
97 test("a duplicate and a bad color are refused", async () => {
98 await call(admin, { repo: R, action: "create", name: "ui" });
99 const dup = await call(admin, { repo: R, action: "create", name: "UI" });
100 expect(dup.error).toContain("taken: A label with that name already exists.");
101 const color = await call(admin, { repo: R, action: "create", name: "x", color: "red" });
102 expect(color.error).toContain("six hex digits");
103 });
104
105 test("a stranger or reader cannot change them, and a missing label is not found", async () => {
106 const before = (await call(admin, { repo: R })).value.items;
107 const c = await call(bob, { repo: R, action: "create", name: "evil" });
108 expect(c.error).toContain("not_found");
109 const r = await call(bob, { repo: R, action: "remove", id: before[0].id });
110 expect(r.error).toContain("not_found");
111 expect((await call(bob, { repo: R })).value.items).toEqual(before);
112 expect((await call(adminRw, { repo: R, action: "create", name: "no" })).error).toContain("admin token");
113 expect((await call(admin, { repo: R, action: "remove", id: "nope" })).error).toContain("not_found");
114 });
115
116 test("a read-only token lists, named or by default, and cannot change", async () => {
117 expect(Array.isArray((await call(ro, { repo: R })).value.items)).toBe(true);
118 expect(Array.isArray((await call(ro, { repo: R, action: "list" })).value.items)).toBe(true);
119 const c = await call(ro, { repo: R, action: "create", name: "x" });
120 expect(c.error).toBe("forbidden: this token may read, not act");
121 const r = await call(ro, { repo: R, action: "remove", id: "nope" });
122 expect(r.error).toBe("forbidden: this token may read, not act");
123 });
124
125 test("bad arguments are one-sentence refusals", async () => {
126 for (const args of [
127 { repo: R, action: "burn" },
128 { repo: R, action: "create" },
129 { repo: R, action: "create", name: 5 },
130 { repo: R, action: "create", name: "x".repeat(65) },
131 { repo: R, action: "remove" },
132 { repo: R, action: "remove", id: 7 },
133 { repo: "nope" },
134 ])
135 expect((await call(admin, args)).error).toBeDefined();
136 });
137 });
test/mcp.test.ts +1
@@ -252,6 +252,7 @@ describe("the forge over MCP", () => {
252252 "spend",
253253 "usage",
254254 "labels",
255 "repo_labels",
255256 "wait_for",
256257 "confirm_wait",
257258 "attach_look",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs