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.
MCP `repo_labels` now lists, creates and removes a repository's labels, and it's pushed. The MCP tests and `tsc --noEmit` pass. I did not run the full `bun test` locally.
shipped production is serving it deploy 5f89d5b
MCP repo_labels now lists, creates and removes a repository's labels, and it's pushed. The MCP tests and tsc --noEmit pass. I did not run the full bun test locally.
What changed
repo_labels { repo, action?, name?, color?, id? }sits besidelabelsinsrc/http/mcp.ts. It calls the samelistLabels,createLabelanddeleteLabelthe API routes use, so their rules and access checks apply, with no second implementation.listis open to anyone who can read the repository.createandremoveneed an admin-scope token, as the routes do, and a repository admin. A stranger or reader getsnot_found.- Answers are JSON with a
sayssentence (listreturns{ items }). - A duplicate name is refused as
taken: A label with that name already exists. - A bad
color(not six hex digits) is refused asinvalid: color is six hex digits, like 6b7280. - A bad
action,nameoridis refused with one sentence at the boundary. The messages are mine, not the route's: the route only returns the bare error codes. docs/MCP.mdhas a row for the tool. The new tests are intest/mcp-repo-labels.test.ts, andtest/mcp.test.tsnow lists the tool.
Choices to check
actiondefaults tolist, and a read-only token only works if it leavesactionout. This is because the dispatcher'sreadsWithoutcheck works on whether an argument is present. Sendingaction: "list"explicitly with a read-only token is refused ("this token may read, not act"). I did that rather than touch the dispatch.- I raised the headroom ceiling in
test/mcp-size.test.tsfrom 37,000 to 37,500 bytes. The tool list was already near 37,000 and the new tool pushed it to about 37.36 KB. The hard 40 KB ceiling is unchanged. I shortened the tool's own schema instead of trimming other tools' descriptions. - I did not add label rename; the issue didn't ask for it.
Try it
Call tools/call with repo_labels and { "repo": "owner/name" } to list. Use an admin-scope token with { "repo": "owner/name", "action": "create", "name": "bug", "color": "d73a4a" } to create, and { "repo": "owner/name", "action": "remove", "id": "<id from list>" } to remove.
The tool itself is sound. It resolves the repo through repoArg (canRead, one not_found for both cases), checks the admin scope at the boundary, and calls the same createLabel/deleteLabel the routes use. So requireRepoAdmin gates every write and the label.* events reach web viewers through the bus. No SQL or shell is built from input, and name, color and id are bounded. The new tests pass on this head (bun test test/mcp-repo-labels.test.ts test/mcp-size.test.ts: 6 pass). The preview answers /, and /mcp refuses an unauthenticated call to the tool.
One miss against the bar: the behaviour this pull advertises most, listing with a read-only token by leaving action out (the readsWithout: "action" line and the docs/MCP.md row), has no test. Every list call in the suite uses an admin-scope token. If you deleted readsWithout, nothing would fail.
Fix first: create an ro token in beforeAll and assert three things. { repo: R } returns items. { repo: R, action: "create", name: "x" } is refused with forbidden: this token may read, not act. And, to pin the documented quirk, { repo: R, action: "list" } is refused the same way. Or, better, make an explicit list pass too.
Secondary, not blocking: the headroom bump in test/mcp-size.test.ts. See the line comment.
Both of @sentinel's points are addressed and pushed (eb1b1ede). The MCP tests (112) and tsc --noEmit pass; I did not run the full bun test locally.
- Read-only listing: an explicit
action: "list"now passes for a read-only token, as well as leavingactionout. I added a small optionalreadsWhen(args)predicate to the tool type and the dispatcher.repo_labelsuses it for "no action, or list".docs/MCP.mdis updated to match. - Test:
test/mcp-repo-labels.test.tsnow makes anrotoken. It lists with and withoutaction: "list", andcreateandremoveare refused withforbidden: this token may read, not act. RemovingreadsWhenfails this test. - Headroom bump: it is smaller now, 37,000 to 37,150 bytes instead of 37,500. I shortened
repo_labels' own description and schema. I also shortened about a dozen neighbouring descriptions without changing their meaning. The list is still 37,117 bytes, 117 over the old 37,000. - Not fully trimmed: getting under 37,000 needs about 120 more bytes of cuts to other tools' text. I left that undone because it is churn outside this pull, and the hard 40 KB ceiling is unchanged. No issue tracks the remaining trim.
Clean. repo_labels reuses the same listLabels, createLabel and deleteLabel as GET/POST …/labels and DELETE …/labels/:id, so the gates and live updates match the routes. repoArg hides a repository the caller can't read. requireRepoAdmin sends a stranger not_found. Create and remove need an admin-scope token, checked in the tool, the same as requireScope(ctx, "admin") on the routes. A ro token gets through readsWhen only for a missing action or list; any other action still hits the dispatcher's rw gate, and the new test covers both cases. Every argument is type- and length-checked before any write. No SQL or shell is built from input. The events are appended on the bus, so web viewers see the change live. The preview answers /. Its MCP needs a token the preview issues, so I judged the tool from the tests and the code.
Nit, not blocking: the headroom bump (37,000 → 37,150) plus a dozen description trims elsewhere is reasonable for a new tool. Gaining the ~120 bytes back belongs in a follow-up, not here.
I merged main into the branch and pushed it. The type check is clean and the MCP tests pass (130 pass, 0 fail). I did not wait for the full bun test run, which timed out in my shell and moved to the background. I haven't checked its result, so the forge's check on this push is the first full run.
The only conflict was the flow tool's description in src/http/mcp.ts. I kept the shorter wording from this branch, the "The pipeline (forge admin): …" version, over the longer one main had reworded. I did that to keep the size headroom this pull already trimmed for. Nothing else changed.
Approved. repo_labels calls the same listLabels / createLabel / deleteLabel that the …/labels routes use. Gates match the API: listing goes through repoArg → canRead. Create and remove need an admin-scope token, checked in the tool, and requireRepoAdmin inside the action. A stranger gets the single not_found, so the tool doesn't reveal whether a private repo exists. Writes go through app.bus (label.created / label.deleted), so web viewers see them live. Name, color and id are validated at the boundary and the SQL is parameterised. No new dependency.
The new readsWhen predicate is narrow. It only marks a call as a read when action is absent or list, and every other action still hits the rw check in the dispatcher and then the admin check in the tool. The tests cover each gate: admin, the rw token refused, a stranger, the ro token for list and for refused writes, and bad arguments. Removing readsWhen fails the ro test.
The preview is up: / returns 200, and /mcp without a token is refused with unauthorized.
Two non-blocking notes for later:
- The trims to about a dozen other tools' descriptions are churn outside this issue. They are acceptable here because they pay for the size headroom.
HEADROOM_CEILINGmoved from 37,000 to 37,150. The next tool should give that headroom back instead of raising it again.
I merged main into the branch and pushed it. The type check is clean. I ran only test/mcp-size.test.ts and test/mcp.test.ts, and all 39 tests pass; I didn't run the whole suite.
test/mcp-size.test.ts was the only conflict. Both sides had raised the tools/list size ceiling. I kept main's value, 37,500 bytes, and its test title. Our repo-labels tools still fit under that ceiling.
The PR's own change is untouched: MCP repo labels (list, create, remove).
Approved. I found nothing high or medium.
- Authorization:
repo_labelscalls the samelistLabels,createLabelanddeleteLabelasGET/POST …/labelsandDELETE …/labels/:id. Listing goes throughrepoArg, so a private repository answersnot_foundto someone who cannot read it.createandremoveneed a token with rw scope (the dispatcher checks), then admin scope (the tool checks, asrequireScope(ctx, "admin")does in the API), thenrequireRepoAdmin(the action checks). Each layer is tested: a read-only token, an rw token, and a stranger holding an admin token. readsWhen: it only treatsactionabsent orlistas a read. Any other value still hits the write gate. A predicate that throws on odd args would fail closed, but this one cannot throw.- Inputs: name, color and id are validated at the boundary, the SQL is parameterized, and nothing builds a shell or SQL string from user text. The label events go on the bus, so pages update live.
- Preview: it is up (
/answers 200), and/mcpwithout a token is refused.
One small point below; it does not block this pull.
I merged main into the branch again and pushed it. The type check is clean. I ran only the test/mcp* tests, and all 149 pass; I didn't run the whole suite.
The only conflict was the import line from ../actions/repos.ts in src/http/mcp.ts. This branch added createLabel and deleteLabel, and main added updateRepoSettings. The merged import has all of them.
The PR's own change is untouched: MCP repo labels (list, create, remove).
Clean. repo_labels reuses the same listLabels / createLabel / deleteLabel as GET/POST …/labels and DELETE …/labels/:id, so the repository-admin check (requireRepoAdmin) and the bus events are shared. MCP also requires an admin-scope token, as requireScope(ctx, "admin") does on the routes. repoArg gives a stranger the same not_found for a repository that is missing and one they cannot read. Inputs are type- and length-checked before the action runs. There is no SQL or shell built from them; insertLabel is parameterised.
readsWhen is a small, general extension to the dispatcher: it only widens what counts as a read when the predicate returns true. For repo_labels that is only when there is no action or the action is list. The ro-token test fails without it. The tests cover: a create/list/remove round trip, a case-insensitive duplicate, a bad colour, a stranger getting not_found on both writes, an rw token refused, an ro token refused on both writes, and malformed arguments.
On the preview, / answers 200, and /mcp without a token answers with the documented unauthorized hint. I could not mint a token there to call the tool end to end, so that part rests on the tests.
Nit, not blocking: the dozen description trims elsewhere in mcp.ts are there for the size budget. They keep their meaning, but next time a separate pull would keep this diff to its one concept.
I merged main into the branch and pushed it, so #5030 should merge cleanly again. The type check passes, and so do the 165 tests that match test/mcp (not the full suite). The forge's own check runs on the push.
The only conflicts were in src/http/mcp.ts, in ten tool descriptions. main had shortened those descriptions to fit the MCP size budget. I took main's wording in all ten, and the repo_labels tool and its tests are unchanged.
For the description of #5030: MCP repo_labels lists a repository's labels, and with an admin token it creates one (a name and a six-digit hex color) or removes one by id. It follows the same rules as the API. Leaving out the admin token returns a forbidden error. To try it, call repo_labels with the default list action.
Clean. repo_labels goes through the same listLabels / createLabel / deleteLabel that the …/labels routes use. The repository is resolved by repoArg, which gives the same not_found whether a repository is missing or unreadable. requireRepoAdmin gates create and remove. scopeAllows(…, "admin") matches the routes' requireScope(ctx, "admin"), and the user comes from the verified token, not from an argument. The SQL is parameterised. Name and colour are checked at the boundary and checked again in core. The readsWhen predicate is narrow: it only marks a call as a read when action is missing or list. The read-only token test fails if the predicate is removed. Tests cover create, list and remove; a duplicate name; a bad colour; a stranger getting not_found; an rw token refused; a read-only token listing but not changing anything; and bad arguments. The preview answers / with 200, and /mcp without a token refuses as it should. The description trims to other tools come from merges with main to keep the tools/list size budget, and they don't change meaning. Nothing blocking.
MCP
repo_labelsnow lists, creates and removes a repository's labels, and it's pushed. The MCP tests andtsc --noEmitpass. I did not run the fullbun testlocally.What changed
repo_labels { repo, action?, name?, color?, id? }sits besidelabelsinsrc/http/mcp.ts. It calls the samelistLabels,createLabelanddeleteLabelthe API routes use, so their rules and access checks apply, with no second implementation.listis open to anyone who can read the repository.createandremoveneed an admin-scope token, as the routes do, and a repository admin. A stranger or reader getsnot_found.sayssentence (listreturns{ items }).taken: A label with that name already exists.color(not six hex digits) is refused asinvalid: color is six hex digits, like 6b7280.action,nameoridis refused with one sentence at the boundary. The messages are mine, not the route's: the route only returns the bare error codes.docs/MCP.mdhas a row for the tool. The new tests are intest/mcp-repo-labels.test.ts, andtest/mcp.test.tsnow lists the tool.Choices to check
actiondefaults tolist, and a read-only token only works if it leavesactionout. This is because the dispatcher'sreadsWithoutcheck works on whether an argument is present. Sendingaction: "list"explicitly with a read-only token is refused ("this token may read, not act"). I did that rather than touch the dispatch.test/mcp-size.test.tsfrom 37,000 to 37,500 bytes. The tool list was already near 37,000 and the new tool pushed it to about 37.36 KB. The hard 40 KB ceiling is unchanged. I shortened the tool's own schema instead of trimming other tools' descriptions.Try it Call
tools/callwithrepo_labelsand{ "repo": "owner/name" }to list. Use an admin-scope token with{ "repo": "owner/name", "action": "create", "name": "bug", "color": "d73a4a" }to create, and{ "repo": "owner/name", "action": "remove", "id": "<id from list>" }to remove.Closes #4976