From 6f8ba7024031a1834616fa46c5a3b367fc1801c9 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Tue, 6 Oct 2026 10:48:27 +0200 Subject: [PATCH 1/5] feat(mcp): add create_custom_attribute tool Co-Authored-By: Claude Sonnet 5.5 --- taiga/mcp_server/server.py | 34 +++++++++++++++++ tests/test_mcp_server.py | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index cb3b42d..c123de0 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,32 @@ 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 + + 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..6722b37 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -464,6 +464,84 @@ 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") + + # --- User stories ----------------------------------------------------------------------- From 59eca0a10ab67336ebe9371aed5313accde17b0f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Tue, 6 Oct 2026 10:49:09 +0200 Subject: [PATCH 2/5] docs(mcp): document create_custom_attribute Co-Authored-By: Claude Sonnet 5.5 --- changes/14050.feature | 1 + docs/mcp.rst | 14 +++++++++++++- tests/test_mcp_server_cli.py | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 changes/14050.feature diff --git a/changes/14050.feature b/changes/14050.feature new file mode 100644 index 0000000..8b44175 --- /dev/null +++ b/changes/14050.feature @@ -0,0 +1 @@ +Add the ``create_custom_attribute`` MCP tool, which creates 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..950e7c2 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -227,6 +227,18 @@ 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. + ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -473,6 +485,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``, ``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/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"]) From fba0752d92bd715aa2b631f87054003616569b59 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Tue, 6 Oct 2026 11:17:38 +0200 Subject: [PATCH 3/5] feat(mcp): add delete_custom_attribute tool Co-Authored-By: Claude Sonnet 5.5 --- changes/14050.feature | 2 +- docs/mcp.rst | 10 ++++- taiga/mcp_server/server.py | 30 +++++++++++++++ tests/test_mcp_server.py | 79 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/changes/14050.feature b/changes/14050.feature index 8b44175..a899270 100644 --- a/changes/14050.feature +++ b/changes/14050.feature @@ -1 +1 @@ -Add the ``create_custom_attribute`` MCP tool, which creates 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``. +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 950e7c2..e8fa32c 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -239,6 +239,14 @@ Available tools 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``). Destructive: Taiga also drops every value stored under + that attribute on the project's items. Attribute ids are global across + projects, so ``project`` must be the one that owns the attribute - the call + is refused, and nothing deleted, otherwise. Returns + ``{"status": "deleted", "id": ..., "name": ...}``. + ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -485,6 +493,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. -``create_custom_attribute``, ``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 c123de0..1a8ac7f 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -444,6 +444,36 @@ def create_custom_attribute( 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, +) -> 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 or from + `create_custom_attribute`. Attribute ids are global across projects, so `project` must be the one + that owns the attribute; the call is refused otherwise and nothing is deleted. + """ + 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" + ) + 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": attribute.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 6722b37..74304f9 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 @@ -542,6 +543,84 @@ def test_create_custom_attribute_surfaces_the_real_taiga_error(mock_get_client): 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) + + 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) + + 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) + + 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) + + 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) + + # --- User stories ----------------------------------------------------------------------- From cdfd016c0148947a0c7cd4e4cc94c52975f26cd4 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Tue, 6 Oct 2026 11:24:05 +0200 Subject: [PATCH 4/5] test(mcp): pin delete_custom_attribute refusal when the owner is missing Co-Authored-By: Claude Sonnet 5.5 --- tests/test_mcp_server.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 74304f9..f9bd841 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -580,6 +580,20 @@ def test_delete_custom_attribute_refuses_an_attribute_of_another_project(mock_ge attribute.delete.assert_not_called() +@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) + + 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() From 2307b6e4bd11f57212e9aae420eba744c9cde2f7 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Tue, 6 Oct 2026 11:26:55 +0200 Subject: [PATCH 5/5] fix(mcp): require the attribute name to delete a custom attribute Attribute ids are numbered per entity type, so the same id can name a different attribute on another kind. delete_custom_attribute now takes an expected_name and refuses, deleting nothing, when it does not match. Co-Authored-By: Claude Sonnet 5.5 --- docs/mcp.rst | 11 ++++++---- taiga/mcp_server/server.py | 13 ++++++++---- tests/test_mcp_server.py | 43 ++++++++++++++++++++++++++++++++------ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index e8fa32c..03c1c5e 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -241,10 +241,13 @@ Available tools ``delete_custom_attribute`` Delete a custom-attribute *definition* (``entity_type``, ``project``, - ``attribute_id``). Destructive: Taiga also drops every value stored under - that attribute on the project's items. Attribute ids are global across - projects, so ``project`` must be the one that owns the attribute - the call - is refused, and nothing deleted, otherwise. Returns + ``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`` diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 1a8ac7f..9359dd3 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -449,13 +449,15 @@ 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 or from - `create_custom_attribute`. Attribute ids are global across projects, so `project` must be the one - that owns the attribute; the call is refused otherwise and nothing is deleted. + `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: @@ -466,12 +468,15 @@ def delete_custom_attribute( 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": attribute.name} + return {"status": "deleted", "id": str(attribute_id), "name": name} def _membership_names(proj: Any, wanted: set[int]) -> dict[int, str | None]: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index f9bd841..d4b76b8 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -558,7 +558,7 @@ def test_delete_custom_attribute_routes_every_entity_type(mock_get_client): attribute = _attribute() factory.return_value.get.return_value = attribute - result = server.delete_custom_attribute(entity_type, 42, 449) + 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) @@ -575,11 +575,42 @@ def test_delete_custom_attribute_refuses_an_attribute_of_another_project(mock_ge with patch.dict(server._ATTRIBUTE_FACTORY, factories): with pytest.raises(ToolError, match="belongs to project 99"): - server.delete_custom_attribute("user_story", 42, 449) + 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() @@ -589,7 +620,7 @@ def test_delete_custom_attribute_refuses_an_attribute_with_no_owner(mock_get_cli with patch.dict(server._ATTRIBUTE_FACTORY, factories): with pytest.raises(ToolError, match="belongs to project None"): - server.delete_custom_attribute("user_story", 42, 449) + server.delete_custom_attribute("user_story", 42, 449, "Estimation") attribute.delete.assert_not_called() @@ -602,7 +633,7 @@ def test_delete_custom_attribute_falls_back_to_project_id(mock_get_client): factories["issue"].return_value.get.return_value = attribute with patch.dict(server._ATTRIBUTE_FACTORY, factories): - result = server.delete_custom_attribute("issue", 42, 449) + result = server.delete_custom_attribute("issue", 42, 449, "Estimation") attribute.delete.assert_called_once_with() assert result["status"] == "deleted" @@ -618,7 +649,7 @@ def test_delete_custom_attribute_resolves_a_project_slug(mock_get_client): factories["task"].return_value.get.return_value = attribute with patch.dict(server._ATTRIBUTE_FACTORY, factories): - server.delete_custom_attribute("task", "desktop-gym", 449) + 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() @@ -632,7 +663,7 @@ def test_delete_custom_attribute_surfaces_the_real_taiga_error(mock_get_client): with patch.dict(server._ATTRIBUTE_FACTORY, factories): with pytest.raises(ToolError, match="Not found"): - server.delete_custom_attribute("epic", 42, 449) + server.delete_custom_attribute("epic", 42, 449, "Estimation") # --- User stories -----------------------------------------------------------------------