Public: anyone can read this; the crew can change it.
MCP `repo_labels`: list, create and remove a repository's labels #5030
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4976-mcp-repo-labels-list-create-and-remove-a → main 6 commits fork 5 threads · 5 unresolved
4 files
changed +208
−9
docs/MCP.md +1
| @@ -84,6 +84,7 @@ for a tunnel and a forge that sleeps. | ||
| 84 | 84 | | `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 | |
| 85 | 85 | | `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 | | |
| 86 | 86 | | `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 | | |
| 87 | 88 | | `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 | |
| 88 | 89 | | `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 | |
| 89 | 90 | | `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 { | ||
| 138 | 138 | listAssignedTo, |
| 139 | 139 | listComments, |
| 140 | 140 | listOpenAgentPullsVisibleTo, |
| 141 | listLabels, | |
| 141 | 142 | } from "../core/issues.ts"; |
| 142 | 143 | import { isCrew, type Agent } from "../core/agents.ts"; |
| 143 | 144 | import { dependenciesOf } from "../core/dependencies.ts"; |
| @@ -189,7 +190,7 @@ import { | ||
| 189 | 190 | directionNote, |
| 190 | 191 | OUTSIDERS_NOTE, |
| 191 | 192 | } 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"; | |
| 193 | 194 | import { |
| 194 | 195 | deleteAppEnvVar, |
| 195 | 196 | listAppEnv, |
| @@ -302,6 +303,8 @@ interface Tool { | ||
| 302 | 303 | readonly needsAdminScope?: true; |
| 303 | 304 | /** With this argument absent the tool only reads, whatever the token. */ |
| 304 | 305 | readonly readsWithout?: string; |
| 306 | /** A call this accepts only reads, whatever the token. */ | |
| 307 | readonly readsWhen?: (args: Args) => boolean; | |
| 305 | 308 | /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */ |
| 306 | 309 | readonly dryByDefault?: true; |
| 307 | 310 | run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer; |
| @@ -515,9 +518,9 @@ const TOOLS: readonly Tool[] = [ | ||
| 515 | 518 | type: "object", |
| 516 | 519 | properties: { |
| 517 | 520 | 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." }, | |
| 519 | 522 | 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." }, | |
| 521 | 524 | agent: { type: "string", maxLength: 39, description: "Agent for the crew and first issue." }, |
| 522 | 525 | pick: { type: "boolean", description: "Pick the agent from the text." }, |
| 523 | 526 | }, |
| @@ -1760,8 +1763,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1760 | 1763 | type: "object", |
| 1761 | 1764 | properties: { |
| 1762 | 1765 | 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)." }, | |
| 1765 | 1768 | dryRun: { type: "boolean", description: "false to apply" }, |
| 1766 | 1769 | }, |
| 1767 | 1770 | required: ["username", "dailyUsd", "perRunUsd"], |
| @@ -3635,6 +3638,62 @@ const TOOLS: readonly Tool[] = [ | ||
| 3635 | 3638 | }); |
| 3636 | 3639 | }, |
| 3637 | 3640 | }, |
| 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 | }, | |
| 3638 | 3697 | { |
| 3639 | 3698 | name: "wait_for", |
| 3640 | 3699 | description: "Make an issue wait for another; `stop: true` ends it.", |
| @@ -4283,7 +4342,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4283 | 4342 | { |
| 4284 | 4343 | name: "quotas", |
| 4285 | 4344 | 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.", | |
| 4287 | 4346 | inputSchema: { |
| 4288 | 4347 | type: "object", |
| 4289 | 4348 | properties: { |
| @@ -4380,10 +4439,10 @@ const TOOLS: readonly Tool[] = [ | ||
| 4380 | 4439 | type: "object", |
| 4381 | 4440 | properties: { |
| 4382 | 4441 | 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." }, | |
| 4384 | 4443 | from: { type: "string", maxLength: 64 }, |
| 4385 | 4444 | 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." }, | |
| 4387 | 4446 | }, |
| 4388 | 4447 | }, |
| 4389 | 4448 | writes: false, |
| @@ -4587,7 +4646,8 @@ function rpcOne( | ||
| 4587 | 4646 | : {}; |
| 4588 | 4647 | const plansOnly = |
| 4589 | 4648 | (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; | |
| 4591 | 4651 | if (tool.writes && !plansOnly && !scopeAllows(ctx.auth.scope ?? "ro", "rw")) |
| 4592 | 4652 | return reply({ |
| 4593 | 4653 | 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", () => { | ||
| 252 | 252 | "spend", |
| 253 | 253 | "usage", |
| 254 | 254 | "labels", |
| 255 | "repo_labels", | |
| 255 | 256 | "wait_for", |
| 256 | 257 | "confirm_wait", |
| 257 | 258 | "attach_look", |
new above
offline