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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7497-mcp-repo-labels-renames-or-recolours-a-l → main 1 commit fork 2 threads · 2 unresolved
docs/MCP.md +1 −1
| @@ -138,7 +138,7 @@ for a tunnel and a forge that sleeps. | ||
| 138 | 138 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | | |
| 139 | 139 | | `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 | | |
| 140 | 140 | | `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 | | |
| 142 | 142 | | `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 | |
| 143 | 143 | | `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 | |
| 144 | 144 | | `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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | export const LABEL_WORDS = { |
| 7 | 7 | 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.", | |
| 8 | 10 | } as const; |
src/http/mcp/tools/repo_labels.ts +28 −5
| @@ -1,15 +1,15 @@ | ||
| 1 | 1 | import { listLabels } from "../../../core/issues.ts"; |
| 2 | 2 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 3 | import { createLabel, deleteLabel } from "../../../actions/repos.ts"; | |
| 3 | import { createLabel, deleteLabel, updateLabel } from "../../../actions/repos.ts"; | |
| 4 | 4 | import { LABEL_WORDS } from "../../../actions/label_words.ts"; |
| 5 | 5 | import { pageRows } from "../page.ts"; |
| 6 | 6 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 7 | 7 | |
| 8 | 8 | export const tool: Tool = { |
| 9 | 9 | 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", | |
| 11 | 11 | description: |
| 12 | "Repo labels: list (default), create, remove (admin token).", | |
| 12 | "Repo labels: list (default), create, update, remove (admin token).", | |
| 13 | 13 | inputSchema: { |
| 14 | 14 | type: "object", |
| 15 | 15 | properties: { |
| @@ -31,8 +31,8 @@ export const tool: Tool = { | ||
| 31 | 31 | if (isAnswer(repo)) return repo; |
| 32 | 32 | const action = args["action"] ?? "list"; |
| 33 | 33 | 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"); | |
| 36 | 36 | // The API asks for an admin token on both routes; so does this. |
| 37 | 37 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) |
| 38 | 38 | return bad("forbidden", "changing labels needs an admin token"); |
| @@ -58,6 +58,29 @@ export const tool: Tool = { | ||
| 58 | 58 | const id = args["id"]; |
| 59 | 59 | if (typeof id !== "string" || id === "" || id.length > 64) |
| 60 | 60 | 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: |
||
| 80 | : LABEL_WORDS.shape, | |
| 81 | ); | |
| 82 | return good({ says: `Label “${updated.value.name}” updated.`, label: updated.value }); | |
| 83 | } | |
| 61 | 84 | const gone = deleteLabel(ctx.app, user, repo, id); |
| 62 | 85 | return gone.ok |
| 63 | 86 | ? 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> { | ||
| 136 | 136 | tone: "danger", |
| 137 | 137 | message: |
| 138 | 138 | created.error === "taken" |
| 139 | ? "This repository already has a label by that name." | |
| 139 | ? LABEL_WORDS.taken | |
| 140 | 140 | : LABEL_WORDS.shape, |
| 141 | 141 | }, |
| 142 | 142 | ); |
| @@ -163,9 +163,9 @@ export async function labelUpdate(ctx: Ctx): Promise<Response> { | ||
| 163 | 163 | tone: "danger", |
| 164 | 164 | message: |
| 165 | 165 | updated.error === "taken" |
| 166 | ? "This repository already has a label by that name." | |
| 166 | ? LABEL_WORDS.taken | |
| 167 | 167 | : updated.error === "not_found" |
| 168 | ? "That label no longer exists." | |
| 168 | ? LABEL_WORDS.gone | |
| 169 | 169 | : LABEL_WORDS.shape, |
| 170 | 170 | }, |
| 171 | 171 | ); |
| @@ -186,6 +186,6 @@ export async function labelDelete(ctx: Ctx): Promise<Response> { | ||
| 186 | 186 | `${repoBase(repo)}/settings/labels`, |
| 187 | 187 | removed.ok |
| 188 | 188 | ? { message: "Label deleted." } |
| 189 | : { tone: "danger", message: "That label no longer exists." }, | |
| 189 | : { tone: "danger", message: LABEL_WORDS.gone }, | |
| 190 | 190 | ); |
| 191 | 191 | } |
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 | }); |
Low: this comment says every door reads these sentences "so they cannot drift", but MCP
createstill answerstakenwith "A label with that name already exists." (repo_labels.ts, create branch). Either pointcreateatLABEL_WORDS.takenand update the one assertion intest/mcp-repo-labels.test.ts, or narrow the comment so it doesn't overclaim.