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`)
|
- `create_pr_comment` (`owner`, `repo`, `pull_number`, `body`)
|
||||||
- `add_labels` (`owner`, `repo`, `issue_number`, `labels`)
|
- `add_labels` (`owner`, `repo`, `issue_number`, `labels`)
|
||||||
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
|
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
|
||||||
|
- `create_label` (`owner`, `repo`, `name`, `color` hex e.g. `#00aabb`, optional `description`, `exclusive`)
|
||||||
|
|
||||||
## Validation and Limits
|
## Validation and Limits
|
||||||
|
|
||||||
|
|||||||
@@ -668,6 +668,33 @@ class GiteaClient:
|
|||||||
)
|
)
|
||||||
return result if isinstance(result, dict) else {}
|
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(
|
async def add_labels(
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|||||||
@@ -374,6 +374,24 @@ AVAILABLE_TOOLS: list[MCPTool] = [
|
|||||||
},
|
},
|
||||||
write_operation=True,
|
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,
|
assign_issue_tool,
|
||||||
create_issue_comment_tool,
|
create_issue_comment_tool,
|
||||||
create_issue_tool,
|
create_issue_tool,
|
||||||
|
create_label_tool,
|
||||||
create_pr_comment_tool,
|
create_pr_comment_tool,
|
||||||
update_issue_tool,
|
update_issue_tool,
|
||||||
)
|
)
|
||||||
@@ -342,6 +343,7 @@ TOOL_HANDLERS: dict[str, ToolHandler] = {
|
|||||||
"create_pr_comment": create_pr_comment_tool,
|
"create_pr_comment": create_pr_comment_tool,
|
||||||
"add_labels": add_labels_tool,
|
"add_labels": add_labels_tool,
|
||||||
"assign_issue": assign_issue_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)
|
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:
|
def extract_repository(arguments: dict[str, object]) -> str | None:
|
||||||
"""Extract `owner/repo` from raw argument mapping.
|
"""Extract `owner/repo` from raw argument mapping.
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,41 @@ from aegis_gitea_mcp.tools.arguments import (
|
|||||||
AssignIssueArgs,
|
AssignIssueArgs,
|
||||||
CreateIssueArgs,
|
CreateIssueArgs,
|
||||||
CreateIssueCommentArgs,
|
CreateIssueCommentArgs,
|
||||||
|
CreateLabelArgs,
|
||||||
CreatePrCommentArgs,
|
CreatePrCommentArgs,
|
||||||
UpdateIssueArgs,
|
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]:
|
async def create_issue_tool(gitea: GiteaClient, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Create a new issue in write mode."""
|
"""Create a new issue in write mode."""
|
||||||
parsed = CreateIssueArgs.model_validate(arguments)
|
parsed = CreateIssueArgs.model_validate(arguments)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from aegis_gitea_mcp.tools.write_tools import (
|
|||||||
assign_issue_tool,
|
assign_issue_tool,
|
||||||
create_issue_comment_tool,
|
create_issue_comment_tool,
|
||||||
create_issue_tool,
|
create_issue_tool,
|
||||||
|
create_label_tool,
|
||||||
create_pr_comment_tool,
|
create_pr_comment_tool,
|
||||||
update_issue_tool,
|
update_issue_tool,
|
||||||
)
|
)
|
||||||
@@ -97,6 +98,9 @@ class StubGitea:
|
|||||||
async def assign_issue(self, owner, repo, index, assignees):
|
async def assign_issue(self, owner, repo, index, assignees):
|
||||||
return {"assignees": [{"login": user} for user in 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):
|
class ErrorGitea(StubGitea):
|
||||||
"""Stub that raises backend errors for failure-mode coverage."""
|
"""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"]},
|
{"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["alice"]},
|
||||||
"assignees",
|
"assignees",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
create_label_tool,
|
||||||
|
{"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"},
|
||||||
|
"id",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_write_tools_success(tool, args, expected_key):
|
async def test_write_tools_success(tool, args, expected_key):
|
||||||
"""Write tools should normalize successful backend responses."""
|
"""Write tools should normalize successful backend responses."""
|
||||||
result = await tool(StubGitea(), args)
|
result = await tool(StubGitea(), args)
|
||||||
assert expected_key in result
|
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