diff --git a/changes/14050.feature b/changes/14050.feature new file mode 100644 index 0000000..a899270 --- /dev/null +++ b/changes/14050.feature @@ -0,0 +1 @@ +Add the ``create_custom_attribute`` and ``delete_custom_attribute`` MCP tools, which create and delete a custom-attribute definition for user stories, tasks, issues or epics, so agents can set up an attribute on a board before writing values with ``set_custom_attribute_value``. diff --git a/docs/mcp.rst b/docs/mcp.rst index b1869c8..03c1c5e 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -227,6 +227,29 @@ Available tools prior ``get_custom_attributes_values`` call, not the entity's own ``version``. +``create_custom_attribute`` + Create a custom-attribute *definition* for user stories, tasks, issues or + epics in a project (``entity_type``, ``project``, ``name``, optional + ``description`` and ``type``). Returns the new definition; its ``id`` is the + ``attribute_id`` that ``set_custom_attribute_value`` takes. Taiga rejects a + name already used by the same entity type in that project, so check + ``get_project``'s ``*_custom_attributes`` list first. ``type`` is one of + ``text``, ``multiline``, ``richtext``, ``date``, ``url``, ``dropdown``, + ``checkbox``, ``number`` and defaults to the instance's own default (text) + when omitted. The options of a ``dropdown`` attribute cannot be set through + this tool; add them in the Taiga admin. + +``delete_custom_attribute`` + Delete a custom-attribute *definition* (``entity_type``, ``project``, + ``attribute_id``, ``expected_name``). Destructive: Taiga also drops every + value stored under that attribute on the project's items. Attribute ids are + numbered separately per entity type (the same id can name a different + attribute on another kind) and are global across projects, so take the id + from the ``get_project`` list matching ``entity_type``; the call is refused, + and nothing deleted, unless the attribute belongs to ``project`` and is + named ``expected_name`` (case and surrounding spaces ignored). Returns + ``{"status": "deleted", "id": ..., "name": ...}``. + ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -473,6 +496,6 @@ Taiga projects. Review what an MCP client proposes to do before approving write operations, and consider a dedicated Taiga account with restricted project membership if you want to limit the blast radius. -``set_custom_attribute_value``/``set_custom_attribute_value_by_id`` and +``create_custom_attribute``/``delete_custom_attribute``, ``set_custom_attribute_value``/``set_custom_attribute_value_by_id`` and ``link_epic_user_story``/``link_epic_user_story_by_id`` are also writes and fall under the same destructive-tools framing above. diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index cb3b42d..9359dd3 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -9,6 +9,7 @@ from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.exceptions import ToolError +from ..models import EpicAttributes, IssueAttributes, TaskAttributes, UserStoryAttributes from .auth import get_client from .serialize import apply_payload, to_jsonable @@ -30,6 +31,13 @@ "epic": "epics", } +_ATTRIBUTE_FACTORY = { + "user_story": UserStoryAttributes, + "task": TaskAttributes, + "issue": IssueAttributes, + "epic": EpicAttributes, +} + _REF_METHOD = { "user_story": "get_userstory_by_ref", "task": "get_task_by_ref", @@ -410,6 +418,67 @@ def set_custom_attribute_value_by_id( return to_jsonable(resource.set_attribute(attribute_id, value, version=version)) +@mcp.tool() +def create_custom_attribute( + entity_type: Literal["user_story", "task", "issue", "epic"], + project: str | int, + name: str, + description: str = "", + type: Literal["text", "multiline", "richtext", "date", "url", "dropdown", "checkbox", "number"] | None = None, +) -> dict[str, Any]: + """Create a custom-attribute definition for user stories, tasks, issues or epics in a project. + + Returns the new definition; its `id` is the `attribute_id` that `set_custom_attribute_value` + takes. Taiga rejects a name already used by the same entity type in the project, so look at + `get_project`'s `*_custom_attributes` list first. `type` is left to the instance's default + (text) when omitted; pass "number" for a numeric attribute. + """ + attrs: dict[str, Any] = {"description": description} + if type is not None: + attrs["type"] = type + client = get_client() + try: + factory = _ATTRIBUTE_FACTORY[entity_type](client.raw_request) + return to_jsonable(factory.create(_resolve_project_id(project), name, **attrs)) + except Exception as exc: + raise ToolError(f"{exc.__class__.__name__}: {exc}") from exc + + +@mcp.tool() +def delete_custom_attribute( + entity_type: Literal["user_story", "task", "issue", "epic"], + project: str | int, + attribute_id: int, + expected_name: str, +) -> dict[str, str]: + """Delete a custom-attribute definition (not a single item's value) from a project. + + Destructive: Taiga also drops every value stored under that attribute on the project's items. + `attribute_id` is the numeric id from `get_project`'s `*_custom_attributes` list for the same + `entity_type`, or from `create_custom_attribute`. Ids are numbered separately per entity type + and are global across projects, so the call is refused, and nothing deleted, unless the + attribute belongs to `project` and is named `expected_name` (case and surrounding spaces ignored). + """ + client = get_client() + try: + project_id = _resolve_project_id(project) + attribute = _ATTRIBUTE_FACTORY[entity_type](client.raw_request).get(attribute_id) + owner = getattr(attribute, "project", None) or getattr(attribute, "project_id", None) + if owner != project_id: + raise ToolError( + f"Custom attribute {attribute_id} belongs to project {owner}, not {project_id}; not deleted" + ) + name = getattr(attribute, "name", None) + if str(name).strip().casefold() != expected_name.strip().casefold(): + raise ToolError(f"Custom attribute {attribute_id} is named {name!r}, not {expected_name!r}; not deleted") + attribute.delete() + except ToolError: + raise + except Exception as exc: + raise ToolError(f"{exc.__class__.__name__}: {exc}") from exc + return {"status": "deleted", "id": str(attribute_id), "name": name} + + def _membership_names(proj: Any, wanted: set[int]) -> dict[int, str | None]: """Map user id -> full_name over a project's memberships, paging until `wanted` is covered.""" names: dict[int, str | None] = {} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index bd61bd1..d4b76b8 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest @@ -464,6 +465,207 @@ def test_set_custom_attribute_value_by_id_version_is_optional(mock_get_client): resource.set_attribute.assert_called_once_with(12, "x", version=None) +def _attribute_factories(): + """One MagicMock factory per entity type; calling a factory returns that type's instance.""" + return {entity_type: MagicMock(name=entity_type) for entity_type in server._ATTRIBUTE_FACTORY} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_custom_attribute_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + factories = _attribute_factories() + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + for entity_type, factory in factories.items(): + factory.return_value.create.return_value = {"id": 7, "name": "Estimation", "project": 42} + + result = server.create_custom_attribute(entity_type, 42, "Estimation") + + factory.assert_called_once_with(mock_client.raw_request) + assert result["id"] == 7 + assert result["name"] == "Estimation" + + +@patch("taiga.mcp_server.server.get_client") +def test_create_custom_attribute_sends_description_and_omits_type_by_default(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + server.create_custom_attribute("issue", 42, "Estimation", description="hours") + + factories["issue"].return_value.create.assert_called_once_with(42, "Estimation", description="hours") + + +@patch("taiga.mcp_server.server.get_client") +def test_create_custom_attribute_sends_type_when_given(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + server.create_custom_attribute("user_story", 42, "Estimation", type="number") + + factories["user_story"].return_value.create.assert_called_once_with( + 42, "Estimation", description="", type="number" + ) + + +@patch("taiga.mcp_server.server.get_client") +def test_create_custom_attribute_resolves_numeric_string_and_slug(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get_by_slug.return_value.id = 99 + mock_get_client.return_value = mock_client + factories = _attribute_factories() + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + server.create_custom_attribute("task", "42", "Estimation") + server.create_custom_attribute("task", "nephila", "Estimation") + + create = factories["task"].return_value.create + assert create.call_args_list == [ + call(42, "Estimation", description=""), + call(99, "Estimation", description=""), + ] + mock_client.projects.get_by_slug.assert_called_once_with("nephila") + + +@patch("taiga.mcp_server.server.get_client") +def test_create_custom_attribute_surfaces_the_real_taiga_error(mock_get_client): + # A duplicate name is the expected failure here; without the ToolError wrapper the mcp + # SDK would hide it behind a generic "Error executing tool create_custom_attribute". + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + factories["issue"].return_value.create.side_effect = TaigaRestException("url", 400, '{"name": "Duplicated name"}') + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + with pytest.raises(ToolError, match="Duplicated name"): + server.create_custom_attribute("issue", 42, "Estimation") + + +def _attribute(project=42, **extra): + return SimpleNamespace(id=449, name="Estimation", project=project, delete=MagicMock(), **extra) + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_routes_every_entity_type(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + factories = _attribute_factories() + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + for entity_type, factory in factories.items(): + attribute = _attribute() + factory.return_value.get.return_value = attribute + + result = server.delete_custom_attribute(entity_type, 42, 449, "Estimation") + + factory.assert_called_once_with(mock_client.raw_request) + factory.return_value.get.assert_called_once_with(449) + attribute.delete.assert_called_once_with() + assert result == {"status": "deleted", "id": "449", "name": "Estimation"} + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_refuses_an_attribute_of_another_project(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + attribute = _attribute(project=99) + factories["user_story"].return_value.get.return_value = attribute + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + with pytest.raises(ToolError, match="belongs to project 99"): + server.delete_custom_attribute("user_story", 42, 449, "Estimation") + + attribute.delete.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_refuses_a_different_name(mock_get_client): + # Attribute ids are per entity type, so the same id can name a different attribute + # on another kind: the caller states which one it means and nothing is deleted otherwise. + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + attribute = _attribute() + attribute.name = "Risk" + factories["user_story"].return_value.get.return_value = attribute + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + with pytest.raises(ToolError, match="is named 'Risk', not 'Estimation'; not deleted"): + server.delete_custom_attribute("user_story", 42, 449, "Estimation") + + attribute.delete.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_name_match_ignores_case_and_padding(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + attribute = _attribute() + factories["issue"].return_value.get.return_value = attribute + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + result = server.delete_custom_attribute("issue", 42, 449, " estimation ") + + attribute.delete.assert_called_once_with() + assert result["name"] == "Estimation" + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_refuses_an_attribute_with_no_owner(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + attribute = SimpleNamespace(id=449, name="Estimation", delete=MagicMock()) + factories["user_story"].return_value.get.return_value = attribute + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + with pytest.raises(ToolError, match="belongs to project None"): + server.delete_custom_attribute("user_story", 42, 449, "Estimation") + + attribute.delete.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_falls_back_to_project_id(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + attribute = SimpleNamespace(id=449, name="Estimation", project_id=42, delete=MagicMock()) + factories["issue"].return_value.get.return_value = attribute + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + result = server.delete_custom_attribute("issue", 42, 449, "Estimation") + + attribute.delete.assert_called_once_with() + assert result["status"] == "deleted" + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_resolves_a_project_slug(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get_by_slug.return_value.id = 42 + mock_get_client.return_value = mock_client + factories = _attribute_factories() + attribute = _attribute() + factories["task"].return_value.get.return_value = attribute + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + server.delete_custom_attribute("task", "desktop-gym", 449, "Estimation") + + mock_client.projects.get_by_slug.assert_called_once_with("desktop-gym") + attribute.delete.assert_called_once_with() + + +@patch("taiga.mcp_server.server.get_client") +def test_delete_custom_attribute_surfaces_the_real_taiga_error(mock_get_client): + mock_get_client.return_value = MagicMock() + factories = _attribute_factories() + factories["epic"].return_value.get.side_effect = TaigaRestException("url", 404, '{"_error_message": "Not found"}') + + with patch.dict(server._ATTRIBUTE_FACTORY, factories): + with pytest.raises(ToolError, match="Not found"): + server.delete_custom_attribute("epic", 42, 449, "Estimation") + + # --- User stories ----------------------------------------------------------------------- diff --git a/tests/test_mcp_server_cli.py b/tests/test_mcp_server_cli.py index f7dd9f9..0e7bac8 100644 --- a/tests/test_mcp_server_cli.py +++ b/tests/test_mcp_server_cli.py @@ -110,6 +110,13 @@ def test_list_tools_lists_all_tool_names(): assert "create_issue" in result.output +def test_list_tools_includes_create_custom_attribute(): + result = runner.invoke(cli.app, ["list-tools"]) + + assert result.exit_code == 0 + assert "create_custom_attribute" in result.output + + def test_list_tools_default_excludes_schema(): result = runner.invoke(cli.app, ["list-tools"])