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

MCP `repo_labels` renames or recolours a label with `action: "update"`, as the labels page does #7502

Merged opened this

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

Merged

torque/forge:torque/7497-mcp-repo-labels-renames-or-recolours-a-l → main 1 commit fork 2 threads · 2 unresolved

  • Merged. torque/7497-mcp-repo-labels-renames-or-recolours-a-l was merged into main as e007aae.

Review

Sign in to review this change.
5 files changed +152 −12
docs/MCP.md +1 −1
@@ -138,7 +138,7 @@ for a tunnel and a forge that sleeps.
138138 | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | |
139139 | `releases` | `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | |
140140 | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | |
141 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; 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 |
141 | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes |
142142 | `repo_settings` | `{ repo, description?, visibility?, flowOnly? }` (admin): change a repository's description, visibility and FLOW ONLY switch as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility, flowOnly }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes |
143143 | `report` | `{ repo, title, body?, page? }`: report a problem in a public repository, the one tool open without a token; it still stores the report, which a person reads first; plain text | yes |
144144 | `reports` | `{ repo, id?, action? }`: the waiting reports from outsiders, each `from: "outside"` (their words are unchecked); with `id` and `action` (`accept` opens it as an issue marked as from outside, `dismiss` closes it). A person who writes the repository only; needs an `rw` token | yes |
src/actions/label_words.ts +4 −2
@@ -1,8 +1,10 @@
11 /**
2 * What a label made without a name or a six-digit colour is told, from every
3 * door: the settings form and MCP `repo_labels` read it here, so they cannot drift.
2 * What a label change is told when it is refused, from every door: the
3 * settings form and MCP `repo_labels` read it here, so they cannot drift.

Low: this comment says every door reads these sentences "so they cannot drift", but MCP create still answers taken with "A label with that name already exists." (repo_labels.ts, create branch). Either point create at LABEL_WORDS.taken and update the one assertion in test/mcp-repo-labels.test.ts, or narrow the comment so it doesn't overclaim.

44 */
55
66 export const LABEL_WORDS = {
77 shape: "A label needs a name and a six-digit hex color.",
8 taken: "This repository already has a label by that name.",
9 gone: "That label no longer exists.",
810 } as const;
src/http/mcp/tools/repo_labels.ts +28 −5
@@ -1,15 +1,15 @@
11 import { listLabels } from "../../../core/issues.ts";
22 import { scopeAllows } from "../../../core/tokens.ts";
3 import { createLabel, deleteLabel } from "../../../actions/repos.ts";
3 import { createLabel, deleteLabel, updateLabel } from "../../../actions/repos.ts";
44 import { LABEL_WORDS } from "../../../actions/label_words.ts";
55 import { pageRows } from "../page.ts";
66 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
77
88 export const tool: Tool = {
99 name: "repo_labels",
10 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; 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",
10 doc: "`{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin",
1111 description:
12 "Repo labels: list (default), create, remove (admin token).",
12 "Repo labels: list (default), create, update, remove (admin token).",
1313 inputSchema: {
1414 type: "object",
1515 properties: {
@@ -31,8 +31,8 @@ export const tool: Tool = {
3131 if (isAnswer(repo)) return repo;
3232 const action = args["action"] ?? "list";
3333 if (action === "list") return pageRows(listLabels(ctx.app.db, repo.id), (l) => l.id, args);
34 if (action !== "create" && action !== "remove")
35 return bad("invalid", "action is list, create or remove");
34 if (action !== "create" && action !== "update" && action !== "remove")
35 return bad("invalid", "action is list, create, update or remove");
3636 // The API asks for an admin token on both routes; so does this.
3737 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
3838 return bad("forbidden", "changing labels needs an admin token");
@@ -58,6 +58,29 @@ export const tool: Tool = {
5858 const id = args["id"];
5959 if (typeof id !== "string" || id === "" || id.length > 64)
6060 return bad("invalid", "id is the label's id, from list");
61 if (action === "update") {
62 // The page sends both fields; here either may be left out and keeps its value.
63 if (args["name"] === undefined && args["color"] === undefined)
64 return bad("invalid", "update names a new name, a new color, or both");
65 const current = listLabels(ctx.app.db, repo.id).find((l) => l.id === id);
66 if (!current) return bad("not_found", LABEL_WORDS.gone);
67 const name = args["name"] ?? current.name;
68 const color = args["color"] ?? current.color;
69 if (typeof name !== "string" || name.length > 64)
70 return bad("invalid", "name is one to 64 characters");
71 if (typeof color !== "string") return bad("invalid", LABEL_WORDS.shape);
72 const updated = updateLabel(ctx.app, user, repo, id, name, color);
73 if (!updated.ok)
74 return bad(
75 updated.error,
76 updated.error === "taken"
77 ? LABEL_WORDS.taken
78 : updated.error === "not_found"
79 ? LABEL_WORDS.gone

Low: updateLabel answers not_found for a caller who is not the repository's admin as well, so a stranger who can read the repository is told "That label no longer exists." right after list showed it to them. remove, three lines below, says "no such label, or you are not this repository's admin". You already return LABEL_WORDS.gone early when current is missing (line 66), so a not_found from here can only mean "not admin" (or a concurrent delete). Answer remove's honest sentence here.

80 : LABEL_WORDS.shape,
81 );
82 return good({ says: `Label “${updated.value.name}” updated.`, label: updated.value });
83 }
6184 const gone = deleteLabel(ctx.app, user, repo, id);
6285 return gone.ok
6386 ? good({ says: "Removed the label.", removed: id })
src/web/pages/labels.ts +4 −4
@@ -136,7 +136,7 @@ export async function labelCreate(ctx: Ctx): Promise<Response> {
136136 tone: "danger",
137137 message:
138138 created.error === "taken"
139 ? "This repository already has a label by that name."
139 ? LABEL_WORDS.taken
140140 : LABEL_WORDS.shape,
141141 },
142142 );
@@ -163,9 +163,9 @@ export async function labelUpdate(ctx: Ctx): Promise<Response> {
163163 tone: "danger",
164164 message:
165165 updated.error === "taken"
166 ? "This repository already has a label by that name."
166 ? LABEL_WORDS.taken
167167 : updated.error === "not_found"
168 ? "That label no longer exists."
168 ? LABEL_WORDS.gone
169169 : LABEL_WORDS.shape,
170170 },
171171 );
@@ -186,6 +186,6 @@ export async function labelDelete(ctx: Ctx): Promise<Response> {
186186 `${repoBase(repo)}/settings/labels`,
187187 removed.ok
188188 ? { message: "Label deleted." }
189 : { tone: "danger", message: "That label no longer exists." },
189 : { tone: "danger", message: LABEL_WORDS.gone },
190190 );
191191 }
test/mcp-labels-update.test.ts added +115
@@ -0,0 +1,115 @@
1 /** `repo_labels` `action: "update"` renames and recolours a label as the labels page does, with its words. */
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 { LABEL_WORDS } from "../src/actions/label_words.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12
13 describe("MCP repo_labels update", () => {
14 let dataDir: string;
15 let app: App;
16 let servers: Servers;
17 let admin: string;
18 let adminRw: string;
19 let ro: string;
20 let bob: string;
21 let n = 1;
22 const R = "alice/game";
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: n++,
35 method: "tools/call",
36 params: { name: "repo_labels", 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 listed = async () => (await call(admin, { repo: R })).value.items;
44
45 beforeAll(async () => {
46 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-labels-update-"));
47 app = createApp({ dataDir, port: 0 });
48 servers = await serve(app);
49 const mk = async (username: string, first = false) => {
50 const u = await createUser(app.db, {
51 origin: "seed", trusted: true, username,
52 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
53 });
54 if (!u.ok) throw new Error(u.error);
55 return u.value;
56 };
57 const alice = await mk("alice", true);
58 const b = await mk("bob");
59 admin = createToken(app.db, alice.id, "a", "admin").token;
60 adminRw = createToken(app.db, alice.id, "rw", "rw").token;
61 ro = createToken(app.db, alice.id, "ro", "ro").token;
62 bob = createToken(app.db, b.id, "b", "admin").token;
63 const made = await createRepo(app, alice, { name: "game", visibility: "public" });
64 if (!made.ok) throw new Error(String(made.error));
65 }, 30_000);
66
67 afterAll(async () => {
68 servers.stop();
69 app.close();
70 await rm(dataDir, { recursive: true, force: true });
71 });
72
73 test("an admin renames and recolours a label, and the list shows it", async () => {
74 const made = await call(admin, { repo: R, action: "create", name: "bug", color: "d73a4a" });
75 const id = made.value.label.id;
76 const both = await call(admin, { repo: R, action: "update", id, name: "defect", color: "0E8A16" });
77 expect(both.value).toEqual({
78 says: "Label “defect” updated.",
79 label: { id, name: "defect", color: "0e8a16" },
80 });
81 expect(await listed()).toEqual([{ id, name: "defect", color: "0e8a16" }]);
82 // Either field alone keeps the other's value.
83 await call(admin, { repo: R, action: "update", id, name: "Defect" });
84 expect(await listed()).toEqual([{ id, name: "Defect", color: "0e8a16" }]);
85 await call(admin, { repo: R, action: "update", id, color: "aabbcc" });
86 expect(await listed()).toEqual([{ id, name: "Defect", color: "aabbcc" }]);
87 });
88
89 test("a taken name, a bad colour and a missing label are refused in the page's words", async () => {
90 await call(admin, { repo: R, action: "create", name: "ui", color: "6b7280" });
91 const [defect, ui] = await listed();
92 const taken = await call(admin, { repo: R, action: "update", id: ui.id, name: "DEFECT" });
93 expect(taken.error).toBe(`taken: ${LABEL_WORDS.taken}`);
94 const color = await call(admin, { repo: R, action: "update", id: ui.id, color: "red" });
95 expect(color.error).toBe(`invalid: ${LABEL_WORDS.shape}`);
96 const blank = await call(admin, { repo: R, action: "update", id: ui.id, name: " " });
97 expect(blank.error).toBe(`invalid: ${LABEL_WORDS.shape}`);
98 const gone = await call(admin, { repo: R, action: "update", id: "nope", name: "x" });
99 expect(gone.error).toBe(`not_found: ${LABEL_WORDS.gone}`);
100 expect((await call(admin, { repo: R, action: "update", id: ui.id })).error).toContain("invalid");
101 expect((await call(admin, { repo: R, action: "update", name: "x" })).error).toContain("invalid");
102 expect(await listed()).toEqual([defect, ui]);
103 });
104
105 test("a read-only token, a non-admin token and a stranger cannot update", async () => {
106 const before = await listed();
107 const id = before[0].id;
108 expect((await call(ro, { repo: R, action: "update", id, name: "x" })).error).toBe(
109 "forbidden: this token may read, not act",
110 );
111 expect((await call(adminRw, { repo: R, action: "update", id, name: "x" })).error).toContain("admin token");
112 expect((await call(bob, { repo: R, action: "update", id, name: "x" })).error).toContain("not_found");
113 expect(await listed()).toEqual(before);
114 });
115 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs