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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user