From f0db219ee8c032dc7a3458f1abfbca9819db053f Mon Sep 17 00:00:00 2001 From: latte Date: Sun, 14 Jun 2026 20:24:33 +0200 Subject: [PATCH] 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 --- docs/api-reference.md | 1 + src/aegis_gitea_mcp/gitea_client.py | 27 +++++++++++++++++ src/aegis_gitea_mcp/mcp_protocol.py | 18 ++++++++++++ src/aegis_gitea_mcp/server.py | 2 ++ src/aegis_gitea_mcp/tools/arguments.py | 10 +++++++ src/aegis_gitea_mcp/tools/write_tools.py | 30 +++++++++++++++++++ tests/test_tools_extended.py | 37 ++++++++++++++++++++++++ 7 files changed, 125 insertions(+) diff --git a/docs/api-reference.md b/docs/api-reference.md index 6bebf89..269331c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 diff --git a/src/aegis_gitea_mcp/gitea_client.py b/src/aegis_gitea_mcp/gitea_client.py index 4e8f5fe..323f716 100644 --- a/src/aegis_gitea_mcp/gitea_client.py +++ b/src/aegis_gitea_mcp/gitea_client.py @@ -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, diff --git a/src/aegis_gitea_mcp/mcp_protocol.py b/src/aegis_gitea_mcp/mcp_protocol.py index e249af6..9aed0ca 100644 --- a/src/aegis_gitea_mcp/mcp_protocol.py +++ b/src/aegis_gitea_mcp/mcp_protocol.py @@ -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, + ), ] diff --git a/src/aegis_gitea_mcp/server.py b/src/aegis_gitea_mcp/server.py index b5074d8..aa36e9b 100644 --- a/src/aegis_gitea_mcp/server.py +++ b/src/aegis_gitea_mcp/server.py @@ -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, } diff --git a/src/aegis_gitea_mcp/tools/arguments.py b/src/aegis_gitea_mcp/tools/arguments.py index ec573aa..c6523e6 100644 --- a/src/aegis_gitea_mcp/tools/arguments.py +++ b/src/aegis_gitea_mcp/tools/arguments.py @@ -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. diff --git a/src/aegis_gitea_mcp/tools/write_tools.py b/src/aegis_gitea_mcp/tools/write_tools.py index 5af5c24..2b3fea5 100644 --- a/src/aegis_gitea_mcp/tools/write_tools.py +++ b/src/aegis_gitea_mcp/tools/write_tools.py @@ -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) diff --git a/tests/test_tools_extended.py b/tests/test_tools_extended.py index 2b5f4bd..5dedee5 100644 --- a/tests/test_tools_extended.py +++ b/tests/test_tools_extended.py @@ -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")