feat: add create_label write tool

Adds a create_label write-mode tool so labels can be created in a repository
through the MCP server (previously there was no way to define labels, which
blocked attaching labels to issues). Follows the full tool checklist:

- arguments.py: CreateLabelArgs (name, hex color, optional description/exclusive),
  with extra=forbid and a hex-color pattern.
- gitea_client.py: create_label() POSTing to /repos/{owner}/{repo}/labels with
  url-encoded path segments.
- write_tools.py: create_label_tool handler; normalizes the color to a leading
  '#', bounds text output, and lets auth/authz errors surface.
- mcp_protocol.py: register create_label (write_operation=True).
- server.py: wire create_label into TOOL_HANDLERS.
- docs/api-reference.md: document create_label.
- tests: success path, color normalization, and invalid-color rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 20:24:33 +02:00
parent e873d0325b
commit f0db219ee8
7 changed files with 125 additions and 0 deletions
+1
View File
@@ -67,6 +67,7 @@ Scope requirements:
- `create_pr_comment` (`owner`, `repo`, `pull_number`, `body`)
- `add_labels` (`owner`, `repo`, `issue_number`, `labels`)
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
- `create_label` (`owner`, `repo`, `name`, `color` hex e.g. `#00aabb`, optional `description`, `exclusive`)
## Validation and Limits
+27
View File
@@ -668,6 +668,33 @@ class GiteaClient:
)
return result if isinstance(result, dict) else {}
async def create_label(
self,
owner: str,
repo: str,
*,
name: str,
color: str,
description: str = "",
exclusive: bool = False,
) -> dict[str, Any]:
"""Create a repository label."""
payload: dict[str, Any] = {
"name": name,
"color": color,
"description": description,
"exclusive": exclusive,
}
result = await self._request(
"POST",
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}/labels",
json_body=payload,
correlation_id=str(
self.audit.log_tool_invocation(tool_name="create_label", result_status="pending")
),
)
return result if isinstance(result, dict) else {}
async def add_labels(
self,
owner: str,
+18
View File
@@ -374,6 +374,24 @@ AVAILABLE_TOOLS: list[MCPTool] = [
},
write_operation=True,
),
_tool(
"create_label",
"Create a repository label (write-mode only).",
{
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"name": {"type": "string"},
"color": {"type": "string", "description": "Hex color, e.g. #00aabb"},
"description": {"type": "string", "default": ""},
"exclusive": {"type": "boolean", "default": False},
},
"required": ["owner", "repo", "name", "color"],
"additionalProperties": False,
},
write_operation=True,
),
]
+2
View File
@@ -83,6 +83,7 @@ from aegis_gitea_mcp.tools.write_tools import (
assign_issue_tool,
create_issue_comment_tool,
create_issue_tool,
create_label_tool,
create_pr_comment_tool,
update_issue_tool,
)
@@ -342,6 +343,7 @@ TOOL_HANDLERS: dict[str, ToolHandler] = {
"create_pr_comment": create_pr_comment_tool,
"add_labels": add_labels_tool,
"assign_issue": assign_issue_tool,
"create_label": create_label_tool,
}
+10
View File
@@ -220,6 +220,16 @@ class AssignIssueArgs(RepositoryArgs):
assignees: list[str] = Field(..., min_length=1, max_length=20)
class CreateLabelArgs(RepositoryArgs):
"""Arguments for create_label."""
name: str = Field(..., min_length=1, max_length=50)
# Gitea requires a hex color; accept it with or without a leading '#'.
color: str = Field(..., pattern=r"^#?[0-9A-Fa-f]{6}$")
description: str = Field(default="", max_length=1000)
exclusive: bool = Field(default=False)
def extract_repository(arguments: dict[str, object]) -> str | None:
"""Extract `owner/repo` from raw argument mapping.
+30
View File
@@ -16,11 +16,41 @@ from aegis_gitea_mcp.tools.arguments import (
AssignIssueArgs,
CreateIssueArgs,
CreateIssueCommentArgs,
CreateLabelArgs,
CreatePrCommentArgs,
UpdateIssueArgs,
)
async def create_label_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a repository label in write mode."""
parsed = CreateLabelArgs.model_validate(arguments)
# Gitea expects the color with a leading '#'; normalize either form.
color = parsed.color if parsed.color.startswith("#") else f"#{parsed.color}"
try:
label = await gitea.create_label(
parsed.owner,
parsed.repo,
name=parsed.name,
color=color,
description=parsed.description,
exclusive=parsed.exclusive,
)
return {
"id": label.get("id", 0),
"name": limit_text(str(label.get("name", ""))),
"color": label.get("color", ""),
"description": limit_text(str(label.get("description", ""))),
"url": label.get("url", ""),
}
except (GiteaAuthenticationError, GiteaAuthorizationError):
# Let auth/authz failures surface so the server returns actionable
# re-authorization guidance instead of a generic internal error.
raise
except GiteaError as exc:
raise RuntimeError(f"Failed to create label: {exc}") from exc
async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
"""Create a new issue in write mode."""
parsed = CreateIssueArgs.model_validate(arguments)
+37
View File
@@ -22,6 +22,7 @@ from aegis_gitea_mcp.tools.write_tools import (
assign_issue_tool,
create_issue_comment_tool,
create_issue_tool,
create_label_tool,
create_pr_comment_tool,
update_issue_tool,
)
@@ -97,6 +98,9 @@ class StubGitea:
async def assign_issue(self, owner, repo, index, assignees):
return {"assignees": [{"login": user} for user in assignees]}
async def create_label(self, owner, repo, *, name, color, description="", exclusive=False):
return {"id": 5, "name": name, "color": color, "description": description, "url": "u"}
class ErrorGitea(StubGitea):
"""Stub that raises backend errors for failure-mode coverage."""
@@ -169,9 +173,42 @@ async def test_extended_read_tools_failure_mode() -> None:
{"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["alice"]},
"assignees",
),
(
create_label_tool,
{"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"},
"id",
),
],
)
async def test_write_tools_success(tool, args, expected_key):
"""Write tools should normalize successful backend responses."""
result = await tool(StubGitea(), args)
assert expected_key in result
@pytest.mark.asyncio
async def test_create_label_normalizes_color_without_hash() -> None:
"""A hex color without a leading '#' is normalized before hitting Gitea."""
captured: dict = {}
class CaptureStub(StubGitea):
async def create_label(self, owner, repo, *, name, color, description="", exclusive=False):
captured["color"] = color
return {"id": 7, "name": name, "color": color}
result = await create_label_tool(
CaptureStub(),
{"owner": "acme", "repo": "app", "name": "bug", "color": "ff0000"},
)
assert captured["color"] == "#ff0000"
assert result["id"] == 7
def test_create_label_args_reject_invalid_color() -> None:
"""Non-hex color values are rejected at the argument layer."""
import pydantic
from aegis_gitea_mcp.tools.arguments import CreateLabelArgs
with pytest.raises(pydantic.ValidationError):
CreateLabelArgs(owner="o", repo="r", name="bug", color="red")