Live demo covering create/edit/delete/get for repo and org labels, color normalisation, safe-delete in-use guard, and all three URI-addressable resource templates captured against codeberg.org.
13 KiB
Label CRUD + resource-templates (create / edit / delete / get)
2026-06-10T06:30:07Z by Showboat dev
Captured: 2026-06-10 via Showboat
Background
forgejo-mcp previously only offered label read and assignment tools
(list_repo_labels, list_org_labels, add_issue_labels,
remove_issue_labels). Creating, renaming, recoloring, or deleting a label
required falling back to raw curl.
This change adds the missing lifecycle half:
Repo-label CRUD (via forgejo-sdk/v3):
create_repo_label— create a label, get its numeric id backedit_repo_label— PATCH one or more fields (only supplied fields change)delete_repo_label— safe-by-default delete: refuses when the label is in use and reports the reference count;delete_mode=forceoverridesget_repo_label— read one label by id
Org-label CRUD (via raw-HTTP DoJSON — no SDK method exists):
create_org_label/edit_org_label/delete_org_label/get_org_label— same shape as the repo tools; org in-use count is best-effort over repos the token can see
Three URI-addressable resource templates:
forgejo://repo/{owner}/{repo}/label/{id}— single labelforgejo://repo/{owner}/{repo}/labels{?page,limit}— bounded listforgejo://org/{org}/labels{?page,limit}— bounded org-label list
All eight tools share a color normaliser that accepts rrggbb or #rrggbb
(6-digit hex only) and prepends # if absent.
Replay setup
export FORGEJO_URL=https://codeberg.org
export FORGEJO_ACCESS_TOKEN=<your-token>
export FORGEJO_MCP_BIN="${FORGEJO_MCP_BIN:-./forgejo-mcp}"
make build # produces ./forgejo-mcp
Spec: openspec/changes/label-crud/specs/label-crud/spec.md
openspec/changes/label-crud/specs/mcp-resource-label/spec.md
Issue: codeberg.org/goern/forgejo-mcp/issues/190
1. Tool surface
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli list 2>/dev/null | grep -E "(create|edit|delete|get)_(repo|org)_label"
create_org_label Create an organization-level label. Returns the created label including its numeric id.
create_repo_label Create a repository label. Returns the created label including its numeric id.
delete_org_label Delete an organization-level label. By default refuses if the label is in use; set delete_mode=force to override. Note: in-use count is best-effort over repos visible to the token and may under-count.
delete_repo_label Delete a repository label. By default refuses if the label is in use; set delete_mode=force to override.
edit_org_label Edit an organization-level label (PATCH — only supplied fields change). Providing no fields is an error.
edit_repo_label Edit a repository label (PATCH — only supplied fields change). Providing no fields is an error.
get_org_label Get a single organization-level label by ID.
get_repo_label Get a single repository label by ID.
2. create_repo_label — full create/read cycle
Create a label, then read it back by id to verify the returned fields.
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli create_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","name":"Demo/Label","color":"0e8a16","description":"Showboat demo label"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"created: id={r[\"id\"]} name={r[\"name\"]} color={r[\"color\"]} description={r[\"description\"]}\")
"
created: id=1761185 name=Demo/Label color=0e8a16 description=Showboat demo label
Color (no leading ) was normalised to by the server before it reached the API — no . The returned uid=1000(goern) gid=1000(goern) groups=1000(goern),10(wheel) (1761185) is what all subsequent tools use.
Color 0e8a16 (no leading #) was normalised to #0e8a16 before reaching the API — no 422. The returned id (1761185) is what all subsequent tools use.
3. get_repo_label — read one label by id
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli get_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"id={r[\"id\"]} name={r[\"name\"]} color={r[\"color\"]} url={r[\"url\"]}\")
"
id=1761185 name=Demo/Label color=0e8a16 url=https://codeberg.org/api/v1/repos/goern/forgejo-mcp/labels/1761185
4. edit_repo_label — PATCH semantics (only supplied fields change)
Change only the color; name and description stay untouched.
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli edit_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185,"color":"0288d1"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"id={r[\"id\"]} name={r[\"name\"]} color={r[\"color\"]} description={r[\"description\"]}\")
"
id=1761185 name=Demo/Label color=0288d1 description=Showboat demo label
Color changed to 0288d1; name and description are identical to the values set on creation. PATCH semantics confirmed.
5. Invalid color is rejected at the boundary
No network call is made; the server returns an error before touching the API.
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli create_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","name":"Bad","color":"not-a-color"}' 2>&1 | head -3
2026-06-10 08:30:59 [31mERROR[0m to/to.go:45 color must be a 6-digit hex string (e.g. #0088ff or 0088ff), got "not-a-color"
codeberg.org/goern/forgejo-mcp/v2/pkg/to.ErrorResult
/var/home/goern/Source/codeberg.org/goern/forgejo-mcp/pkg/to/to.go:45
6. delete_repo_label — safe-by-default guard
6a. Refuse when in use (safe mode)
First apply the label to an issue, then attempt deletion without force.
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli add_issue_labels \
--args '{"owner":"goern","repo":"forgejo-mcp","index":190,"labels":"1761185"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
labels = [l[\"name\"] for l in r[\"labels\"]]
print(f\"issue #190 labels: {labels}\")
"
issue #190 labels: ['Demo/Label', 'Kind/Feature', 'Priority/High']
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli delete_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185}' 2>&1 | grep -v "^codeberg" | head -3
2026-06-10 08:31:10 [34mINFO[0m forgejo/forgejo.go:79 Successfully created Forgejo client {"url": "https://codeberg.org/", "token_configured": true, "user_agent": "forgejo-mcp/2.28.0-dev+00221ef"}
2026-06-10 08:31:10 [31mERROR[0m to/to.go:45 label "Demo/Label" is used by 1 issue(s)/PR(s); set delete_mode=force to delete anyway
/var/home/goern/Source/codeberg.org/goern/forgejo-mcp/pkg/to/to.go:45
Demo/Label is applied to issue #190, so the safe-mode guard fires and reports the count. No label was deleted.
6b. Force delete removes the label even when in use
${FORGEJO_MCP_BIN:-./forgejo-mcp} --cli delete_repo_label \
--args '{"owner":"goern","repo":"forgejo-mcp","id":1761185,"delete_mode":"force"}' 2>/dev/null | python3 -c "
import sys, json
data = json.loads(sys.stdin.read())
r = json.loads(data[0][\"text\"])[\"Result\"]
print(f\"deleted={r[\"deleted\"]} id={r[\"id\"]}\")
"
deleted=True id=1761185
7. Resource templates — URI-addressable labels
7a. List registered templates
7. Resource templates — URI-addressable labels
Three new resource templates let MCP-resource-aware clients read labels by URI without tool calls. These are additive — existing tools are unchanged.
| URI | What it returns |
|---|---|
| Single label (JSON) | |
| Bounded list, cap 30 | |
| Bounded org-label list, cap 30 |
The demo uses the MCP stdio transport directly.
7. Resource templates — URI-addressable labels
Three new resource templates let MCP-resource-aware clients read labels by URI without tool calls. These are additive — existing tools are unchanged.
The new templates:
forgejo://repo/{owner}/{repo}/label/{id}— single label (JSON)forgejo://repo/{owner}/{repo}/labels{?page,limit}— bounded list, cap 30forgejo://org/{org}/labels{?page,limit}— bounded org-label list, cap 30
The demo uses the MCP stdio transport directly.
7a. resources/templates/list — confirm the three label templates are registered
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/templates/list","params":{}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq -r 'select(.id==2) | .result.resourceTemplates[].uriTemplate' | grep label
forgejo://org/{org}/labels
forgejo://repo/{owner}/{repo}/label/{id}
forgejo://repo/{owner}/{repo}/labels
7b. Read single label — forgejo://repo/{owner}/{repo}/label/{id}
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"forgejo://repo/goern/forgejo-mcp/label/335058"}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq 'select(.id==2) | .result.contents[0].text | fromjson'
{
"id": 335058,
"name": "Kind/Feature",
"color": "0288d1",
"description": "New functionality",
"url": "https://codeberg.org/api/v1/repos/goern/forgejo-mcp/labels/335058"
}
7c. Read bounded label list — forgejo://repo/{owner}/{repo}/labels
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"forgejo://repo/goern/forgejo-mcp/labels"}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq 'select(.id==2) | .result.contents[0].text | fromjson | {owner, repo, label_count: (.labels | length), truncated}'
{
"owner": "goern",
"repo": "forgejo-mcp",
"label_count": 26,
"truncated": null
}
26 labels, under the 30-item cap — no truncation sentinel emitted. For a repo with more than 30 labels, the response would include and a field naming as the enumeration fallback.
26 labels, under the 30-item cap — no truncation sentinel emitted.
For repos with more than 30 labels the response includes truncated: true
and a sentinel field naming list_repo_labels as the unbounded fallback.
7d. Read org-label list — forgejo://org/{org}/labels
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"showboat","version":"1"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"forgejo://org/forgejo/labels"}}
' | ${FORGEJO_MCP_BIN:-./forgejo-mcp} -t stdio -url "$FORGEJO_URL" -token "$FORGEJO_ACCESS_TOKEN" 2>/dev/null \
| jq 'select(.id==2) | .result.contents[0].text | fromjson | {org, label_count: (.labels | length), truncated, list_tool}'
{
"org": "forgejo",
"label_count": 17,
"truncated": null,
"list_tool": null
}
8. End-to-end: autonomous label bootstrap workflow
An agent setting up a new repository can bootstrap a full label taxonomy without leaving the MCP loop:
create_repo_labelfor each label — color normalised automatically, numeric id returned for immediate use inadd_issue_labels.get_repo_labelto verify a specific label is what the agent expects.edit_repo_labelto rename or recolor a label after reviewing issue distributions — only the changed field is sent upstream.delete_repo_label(safe mode) to clean up stale labels — the in-use guard prevents silent data loss;delete_mode=forceonly when the agent has confirmed the label should be stripped from all referencing issues.- Resource URIs (
forgejo://repo/{owner}/{repo}/labels) let resource-aware clients cache the current label set between calls without a separatelist_repo_labelsinvocation.
For org-wide triage (e.g. applying a shared priority taxonomy across multiple
repos), the same workflow applies to create_org_label / edit_org_label /
delete_org_label — the org-level delete guard counts usage across all
org repos the token can read and discloses when the count may be
under-reported due to inaccessible repos.