Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/14050.feature
Original file line number Diff line number Diff line change
@@ -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``.
25 changes: 24 additions & 1 deletion docs/mcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
69 changes: 69 additions & 0 deletions taiga/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand Down Expand Up @@ -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] = {}
Expand Down
202 changes: 202 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch

import pytest
Expand Down Expand Up @@ -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 -----------------------------------------------------------------------


Expand Down
7 changes: 7 additions & 0 deletions tests/test_mcp_server_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
Loading