From c40cf8523a50d65e0c2a1f5f4845df4e719579b8 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 15:40:40 +0200 Subject: [PATCH 1/7] fix(mcp): link epics correctly and fill in versions on writes Epic.add_related_user_story now sends `epic`, which Taiga requires, so link_epic_user_story actually links; both link tools raise ToolError with the real message. Single-item update_* tools and update_work_items use the fetched item's version when the caller omits it, and set_custom_attribute_value takes an optional version defaulting to the current custom-attributes-values version. Refs #278 Co-Authored-By: Claude Sonnet 5.5 --- changes/278.bugfix | 1 + changes/278.bugfix.1 | 1 + changes/278.bugfix.2 | 1 + docs/mcp.rst | 21 +++-- taiga/mcp_server/server.py | 42 +++++++--- taiga/models/models.py | 10 ++- tests/test_custom_attributes.py | 32 +++++++- tests/test_epics.py | 2 +- tests/test_mcp_server.py | 140 +++++++++++++++++++++++++++++++- 9 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 changes/278.bugfix create mode 100644 changes/278.bugfix.1 create mode 100644 changes/278.bugfix.2 diff --git a/changes/278.bugfix b/changes/278.bugfix new file mode 100644 index 0000000..1f83dc0 --- /dev/null +++ b/changes/278.bugfix @@ -0,0 +1 @@ +Fix `Epic.add_related_user_story` (and so the MCP `link_epic_user_story` tools) never linking a user story to an epic: Taiga requires `epic` in the request body of `related_userstories` and rejected the call with `{"epic": ["This field is required."]}`. The epic id is now always sent. diff --git a/changes/278.bugfix.1 b/changes/278.bugfix.1 new file mode 100644 index 0000000..4385e84 --- /dev/null +++ b/changes/278.bugfix.1 @@ -0,0 +1 @@ +Make the MCP `link_epic_user_story` and `link_epic_user_story_by_id` tools surface the real failure message instead of a generic "Error executing tool", the same way the single-item `update_*` tools already do. diff --git a/changes/278.bugfix.2 b/changes/278.bugfix.2 new file mode 100644 index 0000000..3fa8b8a --- /dev/null +++ b/changes/278.bugfix.2 @@ -0,0 +1 @@ +Stop MCP writes from failing when the caller leaves `version` out: the single-item `update_*` tools and `update_work_items` now use the version of the item they have just fetched (an explicit `version` still wins), and `set_custom_attribute_value` / `set_custom_attribute_value_by_id` take an optional `version` that defaults to the current version of the custom-attributes-values resource. `CustomAttributeResource.set_attribute` follows the same default instead of silently sending version 1. diff --git a/docs/mcp.rst b/docs/mcp.rst index e846f33..2da8017 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -221,9 +221,11 @@ Available tools .. important:: The ``version`` returned by ``get_custom_attributes_values`` (and expected by ``set_custom_attribute_value``) belongs to that custom-attributes-values resource - a separate version sequence - from the entity's own ``version`` field. Always pass back the - version from a prior ``get_custom_attributes_values`` call (or - ``1`` if never set before), not the entity's own ``version``. + from the entity's own ``version`` field. ``version`` is optional + on ``set_custom_attribute_value``: when omitted, the resource's + current version is used. If you pass one, it must come from a + prior ``get_custom_attributes_values`` call, not the entity's own + ``version``. ``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story`` Manage user stories. @@ -433,11 +435,12 @@ Available tools On ``update_*`` tools, ``"minimal"``/``"none"`` also skip the re-fetch these tools otherwise perform after writing - a latency win, not just a smaller response. ``version`` is not a separate - parameter on any ``create_*``/``update_*`` tool: if Taiga needs it - for optimistic locking, pass it inside ``fields`` yourself, exactly - as today. (``set_custom_attribute_value``/``set_custom_attribute_value_by_id`` - are the exception - see the note above on their own, unrelated - ``version`` sequence.) + parameter on any ``create_*``/``update_*`` tool: if ``fields`` has no + ``version``, the tool uses the current version of the item it has + just fetched for optimistic locking; an explicit ``version`` inside + ``fields`` wins. (``set_custom_attribute_value``/``set_custom_attribute_value_by_id`` + take their own optional ``version`` - see the note above on that + unrelated ``version`` sequence.) .. tip:: ``update_work_items(project, updates, return_representation="full")`` updates a batch of user stories/tasks/issues/epics in one call. @@ -451,6 +454,8 @@ Available tools ``{"status": "error", "entity_type", "ref", "error"}``. Wiki pages aren't supported here (no per-project ``ref``) - use ``update_wiki_page`` directly. + As with the single-item tools, a missing ``version`` in an item's + ``fields`` is filled in from the item just fetched. **************** Security notes diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index ce00b1a..ef9edde 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -87,6 +87,18 @@ def _represent( return {"ok": True, **base} +def _with_version(resource: Any, fields: dict[str, Any]) -> dict[str, Any]: + """Return `fields` with the resource's current `version` added when the caller omitted it. + + The resource was just fetched, so its version is the optimistic-lock token Taiga expects; + leaving it out makes Taiga reject the write. An explicit caller-supplied `version` wins. + """ + version = getattr(resource, "version", None) + if "version" in fields or not isinstance(version, int): + return fields + return {**fields, "version": version} + + def _patch(resource: Any, fields: dict[str, Any]) -> None: """Apply `resource.patch()`, surfacing the real failure message to the caller. @@ -97,6 +109,7 @@ def _patch(resource: Any, fields: dict[str, Any]) -> None: crash. Re-raising as `ToolError` preserves the real message, the same detail `update_work_items` already surfaces per-row for the same underlying failures. """ + fields = _with_version(resource, fields) try: resource.patch(list(fields.keys()), **fields) except Exception as exc: @@ -362,14 +375,14 @@ def set_custom_attribute_value( ref: int, attribute_id: int, value: Any, - version: int, + version: int | None = None, ) -> dict[str, Any]: """Set one custom-attribute value on a user story, task, issue or epic, identified by its per-project ref number. `attribute_id` is the numeric id from get_project's `*_custom_attributes` list (e.g. the "Code" attribute). - `version` is the custom-attributes-values resource's own version (from a - prior get_custom_attributes_values call, or 1 if never set before) - not - the entity's own `version` field. + `version` is optional: when omitted, the custom-attributes-values resource's + current version is used. If given, it is that resource's own version (from a + prior get_custom_attributes_values call) - not the entity's own `version` field. """ resource = _get_by_ref(entity_type, project, ref) return to_jsonable(resource.set_attribute(attribute_id, value, version=version)) @@ -381,7 +394,7 @@ def set_custom_attribute_value_by_id( id: int, # noqa: A002 attribute_id: int, value: Any, - version: int, + version: int | None = None, ) -> dict[str, Any]: """Set a custom-attribute value by database id. @@ -1016,13 +1029,21 @@ def delete_epic_by_id(id: int) -> dict[str, str]: # noqa: A002 return {"status": "deleted", "id": str(id)} +def _link_epic(epic: Any, user_story_id: int) -> dict[str, Any]: + """Link a user story to an epic, surfacing the real failure message to the caller (see `_patch`).""" + try: + return to_jsonable(epic.add_related_user_story(user_story_id)) + except Exception as exc: + raise ToolError(f"{type(exc).__name__}: {exc}") from exc + + @mcp.tool() def link_epic_user_story(project: str | int, epic_ref: int, user_story_ref: int) -> dict[str, Any]: """Link a user story to an epic, identifying both by their per-project ref numbers.""" proj = _resolve_project(project) epic = proj.get_epic_by_ref(epic_ref) user_story = proj.get_userstory_by_ref(user_story_ref) - return to_jsonable(epic.add_related_user_story(user_story.id)) + return _link_epic(epic, user_story.id) @mcp.tool() @@ -1033,7 +1054,7 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An """ client = get_client() epic = client.epics.get(epic_id) - return to_jsonable(epic.add_related_user_story(user_story_id)) + return _link_epic(epic, user_story_id) @mcp.tool() @@ -1047,8 +1068,8 @@ def update_work_items( Each entry in `updates` is `{"entity_type": "user_story"|"task"|"issue"|"epic", "ref": , "fields": {...}}` - `ref` is the per-project ref number (as in `update_user_story` etc.), and `fields` is the dict of attributes to change, exactly as a single-item `update_*` call would - take it. If Taiga needs a `version` for optimistic locking, put it inside that item's own - `fields`, same as today's single-item contract - this tool adds no new version handling. + take it. If `version` is omitted from an item's `fields`, the fetched item's current version + is used for optimistic locking; an explicit `version` wins. Not atomic: items are processed in order, each succeeds or fails independently, and a failure does not roll back or block any other item. Returns one result row per input item, @@ -1066,7 +1087,8 @@ def update_work_items( ref = item["ref"] fields = item["fields"] resource = _get_by_ref(entity_type, project, ref) - resource.patch(list(fields.keys()), **fields) + patch_fields = _with_version(resource, fields) + resource.patch(list(patch_fields.keys()), **patch_fields) if return_representation == "full": client_attr = getattr(get_client(), _ENTITY_ATTR[entity_type]) results.append(to_jsonable(client_attr.get(resource.id))) diff --git a/taiga/models/models.py b/taiga/models/models.py index 939df41..31ee35f 100644 --- a/taiga/models/models.py +++ b/taiga/models/models.py @@ -43,15 +43,17 @@ class CustomAttributeResource(InstanceResource): CustomAttributeResource base class """ - def set_attribute(self, id, value, version=1): # noqa: A002 + def set_attribute(self, id, value, version=None): # noqa: A002 """ Set attribute to a specific value :param id: id of the attribute :param value: value of the attribute - :param version: version of the attribute (default = 1) + :param version: version of the custom-attributes-values resource (default: its current version) """ attributes = self._get_attributes(cache=True) + if version is None: + version = attributes.get("version", 1) formatted_id = f"{id}" attributes["attributes_values"][formatted_id] = value response = self.requester.patch( @@ -332,10 +334,12 @@ def add_related_user_story(self, user_story_id, **attrs): """ Link an existing :class:`UserStory` to this epic. + The API requires ``epic`` in the request body, so it is always sent. + :param user_story_id: id of the :class:`UserStory` to link :param attrs: other optional attributes of the relation """ - attrs.update({"user_story": user_story_id}) + attrs.update({"user_story": user_story_id, "epic": self.id}) response = self.requester.post( "/{endpoint}/{id}/related_userstories", endpoint=self.endpoint, id=self.id, payload=attrs ) diff --git a/tests/test_custom_attributes.py b/tests/test_custom_attributes.py index 1937a30..6d25baa 100644 --- a/tests/test_custom_attributes.py +++ b/tests/test_custom_attributes.py @@ -27,9 +27,39 @@ def test_edit_issue_custom_attribute(self, mock_requestmaker_patch, mock_request "/{endpoint}/custom-attributes-values/{id}", endpoint=Issue.endpoint, id=issue.id, - payload={"attributes_values": {"1": 13}, "version": 1}, + payload={"attributes_values": {"1": 13}, "version": 2}, ) + @patch("taiga.requestmaker.RequestMaker.get") + @patch("taiga.requestmaker.RequestMaker.patch") + def test_set_attribute_uses_the_current_values_version_when_omitted( + self, mock_requestmaker_patch, mock_requestmaker_get + ): + mock_requestmaker_get.return_value = MockResponse( + 200, create_mock_json("tests/resources/issue_customattr_success.json") + ) + mock_requestmaker_patch.return_value = MockResponse( + 200, create_mock_json("tests/resources/issue_customattr_success.json") + ) + rm = RequestMaker("/api/v1", "fakehost", "faketoken") + issue = Issue(rm, id=1, project=1) + issue.set_attribute(1, 13) + self.assertEqual(mock_requestmaker_patch.call_args.kwargs["payload"]["version"], 2) + + @patch("taiga.requestmaker.RequestMaker.get") + @patch("taiga.requestmaker.RequestMaker.patch") + def test_set_attribute_keeps_an_explicit_version(self, mock_requestmaker_patch, mock_requestmaker_get): + mock_requestmaker_get.return_value = MockResponse( + 200, create_mock_json("tests/resources/issue_customattr_success.json") + ) + mock_requestmaker_patch.return_value = MockResponse( + 200, create_mock_json("tests/resources/issue_customattr_success.json") + ) + rm = RequestMaker("/api/v1", "fakehost", "faketoken") + issue = Issue(rm, id=1, project=1) + issue.set_attribute(1, 13, version=9) + self.assertEqual(mock_requestmaker_patch.call_args.kwargs["payload"]["version"], 9) + @patch("taiga.requestmaker.RequestMaker.get") def test_get_issue_custom_attributes(self, mock_requestmaker_get): mock_requestmaker_get.return_value = MockResponse( diff --git a/tests/test_epics.py b/tests/test_epics.py index acd3e72..77f813a 100644 --- a/tests/test_epics.py +++ b/tests/test_epics.py @@ -97,6 +97,6 @@ def test_add_related_user_story(mock_requestmaker_post): "/{endpoint}/{id}/related_userstories", endpoint=Epic.endpoint, id=epic.id, - payload={"user_story": 10}, + payload={"user_story": 10, "epic": 1}, ) assert result == {"id": 5, "epic": 1, "user_story": 10} diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 238b547..dd29ef0 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -435,6 +435,35 @@ def test_set_custom_attribute_value_by_id_routes_every_entity_type(mock_get_clie assert result == {"attributes_values": {"10": "NPH-INT"}, "version": 2} +@patch("taiga.mcp_server.server.get_client") +def test_set_custom_attribute_value_version_is_optional(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_get_client.return_value = mock_client + resource = mock_project.get_task_by_ref.return_value + resource.set_attribute.return_value = {"attributes_values": {"12": "x"}, "version": 2} + + server.set_custom_attribute_value("task", 1, 9, 12, "x") + resource.set_attribute.assert_called_once_with(12, "x", version=None) + + resource.set_attribute.reset_mock() + server.set_custom_attribute_value("task", 1, 9, 12, "x", version=3) + resource.set_attribute.assert_called_once_with(12, "x", version=3) + + +@patch("taiga.mcp_server.server.get_client") +def test_set_custom_attribute_value_by_id_version_is_optional(mock_get_client): + mock_client = MagicMock() + mock_get_client.return_value = mock_client + resource = mock_client.tasks.get.return_value + resource.set_attribute.return_value = {"attributes_values": {"12": "x"}, "version": 2} + + server.set_custom_attribute_value_by_id("task", 1, 12, "x") + + resource.set_attribute.assert_called_once_with(12, "x", version=None) + + # --- User stories ----------------------------------------------------------------------- @@ -1193,7 +1222,7 @@ def test_update_wiki_page_minimal_has_no_ref_key(mock_get_client): assert result == {"id": 6, "version": 2, "content": "Updated"} assert "ref" not in result - mock_resource.patch.assert_called_once_with(["content"], content="Updated") + mock_resource.patch.assert_called_once_with(["content", "version"], content="Updated", version=2) @patch("taiga.mcp_server.server.get_client") @@ -1692,7 +1721,7 @@ def test_update_user_story_minimal_skips_refetch(mock_get_client): result = server.update_user_story(1, 45634, {"subject": "Updated"}, return_representation="minimal") assert result == {"id": 1, "version": 3, "ref": 45634, "subject": "Updated"} - mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_resource.patch.assert_called_once_with(["subject", "version"], subject="Updated", version=3) mock_client.user_stories.get.assert_not_called() @@ -1706,7 +1735,7 @@ def test_update_user_story_by_id_none(mock_get_client): result = server.update_user_story_by_id(1, {"subject": "Updated"}, return_representation="none") assert result == {"ok": True, "id": 1, "version": 4, "ref": 45634} - mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") + mock_resource.patch.assert_called_once_with(["subject", "version"], subject="Updated", version=4) mock_client.user_stories.get.assert_called_once_with(1) @@ -2015,3 +2044,108 @@ def test_update_task_return_representation_none(mock_get_client): assert result == {"ok": True, "id": 2, "version": 3, "ref": 45} mock_client.tasks.get.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_link_epic_user_story_surfaces_the_real_failure_message(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_epic = MagicMock(id=1) + mock_project.get_epic_by_ref.return_value = mock_epic + mock_project.get_userstory_by_ref.return_value = MagicMock(id=10) + mock_epic.add_related_user_story.side_effect = RuntimeError('{"epic": ["This field is required."]}') + mock_get_client.return_value = mock_client + + with pytest.raises(ToolError, match="This field is required"): + server.link_epic_user_story(1, 42, 45634) + + +@patch("taiga.mcp_server.server.get_client") +def test_link_epic_user_story_by_id_surfaces_the_real_failure_message(mock_get_client): + mock_client = MagicMock() + mock_epic = MagicMock() + mock_client.epics.get.return_value = mock_epic + mock_epic.add_related_user_story.side_effect = RuntimeError("boom") + mock_get_client.return_value = mock_client + + with pytest.raises(ToolError, match="RuntimeError: boom"): + server.link_epic_user_story_by_id(1, 10) + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task_fills_in_the_fetched_version_when_omitted(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=1, version=5) + mock_project.get_task_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + server.update_task(1, 45634, {"status": 437}, return_representation="none") + + mock_resource.patch.assert_called_once_with(["status", "version"], status=437, version=5) + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task_keeps_an_explicit_version(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=1, version=5) + mock_project.get_task_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + server.update_task(1, 45634, {"status": 437, "version": 3}, return_representation="none") + + mock_resource.patch.assert_called_once_with(["status", "version"], status=437, version=3) + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task_leaves_fields_alone_when_the_resource_has_no_version(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(spec=["id", "patch"]) + mock_resource.id = 1 + mock_project.get_task_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + server.update_task(1, 45634, {"status": 437}, return_representation="none") + + mock_resource.patch.assert_called_once_with(["status"], status=437) + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_fills_in_the_fetched_version_when_omitted(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=1, version=7) + mock_project.get_task_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + rows = server.update_work_items( + 1, [{"entity_type": "task", "ref": 9, "fields": {"status": 437}}], return_representation="none" + ) + + mock_resource.patch.assert_called_once_with(["status", "version"], status=437, version=7) + assert "error" not in rows[0] + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_keeps_an_explicit_version(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=1, version=7) + mock_project.get_task_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + server.update_work_items( + 1, + [{"entity_type": "task", "ref": 9, "fields": {"status": 437, "version": 2}}], + return_representation="none", + ) + + mock_resource.patch.assert_called_once_with(["status", "version"], status=437, version=2) From 1f7acf6a6cd9e6522a4adb4aedae42c75cd1d5f2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:03:44 +0200 Subject: [PATCH 2/7] Bump develop version [ci skip] --- pyproject.toml | 2 +- taiga/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 72ca971..29ad662 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ commit = true message = "Release {new_version}" commit_args = "--no-verify" tag = false -current_version = "2.0.0b6" +current_version = "2.0.0.b7" parse = """(?x) (?P[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index e0aa5b8..77b0a5b 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "2.0.0.b6" +__version__ = "2.0.0.b7" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] From 0dcc2889dd4a13ced42f4ba530566b428605cacb Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:15:13 +0200 Subject: [PATCH 3/7] fix(mcp): address PR review on media stripping, membership paging and batch project fetch - keep nested media keys in compact/minimal collapse when strip_media=False - page through project memberships when resolving assigned users - resolve the project once per update_work_items batch Co-Authored-By: Claude Sonnet 5.5 --- taiga/mcp_server/serialize.py | 18 ++++++++++++------ taiga/mcp_server/server.py | 29 +++++++++++++++++++++++------ tests/test_mcp_server.py | 29 +++++++++++++++++++++++++++++ tests/test_mcp_server_serialize.py | 10 ++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index c10ada2..c369978 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -76,7 +76,7 @@ def _select_from_item(item: dict[str, Any], paths: list[str]) -> dict[str, Any]: _ADDITIONAL_COLLAPSIBLE_KEYS = frozenset({"invited_by"}) -def collapse_extra_info(data: Any) -> Any: +def collapse_extra_info(data: Any, keep_media: bool = False) -> Any: """Shrink every `*_extra_info` block, plus `invited_by`, to its id plus whichever descriptive label field is present. @@ -86,19 +86,22 @@ def collapse_extra_info(data: Any) -> Any: compact caller can still tell whether an item is closed without expanding the block. `invited_by` (seen on membership records) is a full nested user block too, but doesn't end in `_extra_info`, so it needs naming explicitly rather than falling out of the - suffix check. + suffix check. `keep_media=True` also keeps the avatar/logo keys, for callers that + explicitly disabled media stripping. """ if isinstance(data, dict): result: dict[str, Any] = {} for key, value in data.items(): if (key.endswith("_extra_info") or key in _ADDITIONAL_COLLAPSIBLE_KEYS) and isinstance(value, dict): keep = ("id", *_LABEL_KEYS, *_EXTRA_INFO_EXTRA_KEEP.get(key, ())) + if keep_media: + keep = (*keep, *_AVATAR_KEYS) result[key] = {k: value[k] for k in keep if k in value} else: - result[key] = collapse_extra_info(value) + result[key] = collapse_extra_info(value, keep_media) return result if isinstance(data, list): - return [collapse_extra_info(item) for item in data] + return [collapse_extra_info(item, keep_media) for item in data] return data @@ -190,13 +193,16 @@ def apply_payload( for item in data ] + keep_media = strip_media is False if fields is not None: out = select_fields(data, fields) elif payload == "minimal": minimal_paths = MINIMAL_FIELDS.get(entity) - out = select_fields(data, minimal_paths) if minimal_paths is not None else collapse_extra_info(data) + out = ( + select_fields(data, minimal_paths) if minimal_paths is not None else collapse_extra_info(data, keep_media) + ) elif payload == "compact": - out = collapse_extra_info(data) + out = collapse_extra_info(data, keep_media) else: out = data diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index ef9edde..bfb78e7 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -64,7 +64,11 @@ def _get_by_ref(entity_type: str, project: str | int, ref: int) -> Any: `ref` is the sequential number Taiga shows per project - e.g. the 45634 in `.../issues/45634` - not the database id used internally for update/delete. """ - proj = _resolve_project(project) + return _get_by_ref_in(_resolve_project(project), entity_type, ref) + + +def _get_by_ref_in(proj: Any, entity_type: str, ref: int) -> Any: + """Like `_get_by_ref`, but for an already-resolved project (avoids a re-fetch per item).""" return getattr(proj, _REF_METHOD[entity_type])(ref) @@ -406,6 +410,18 @@ def set_custom_attribute_value_by_id( return to_jsonable(resource.set_attribute(attribute_id, value, version=version)) +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] = {} + page = 1 + while True: + batch = to_jsonable(proj.list_memberships(page=page, page_size=DEFAULT_PAGE_SIZE)) + names.update({m["user"]: m.get("full_name") for m in batch}) + if len(batch) < DEFAULT_PAGE_SIZE or wanted <= names.keys(): + return names + page += 1 + + def _resolve_assigned_users( data: dict[str, Any] | list[dict[str, Any]], ) -> dict[str, Any] | list[dict[str, Any]]: @@ -416,8 +432,8 @@ def _resolve_assigned_users( field seen on other Taiga user blocks (`owner_extra_info`, `assigned_to_extra_info`, ...) - confirmed against a live instance. We still key our own output as `full_name_display`, for consistency with those other blocks; only the source field - read from the membership record differs. Fetches at most one membership page (up to - 100 members) per distinct project id actually referenced. + read from the membership record differs. Pages through each distinct referenced + project's memberships until every assigned user id is found or the pages run out. """ items = data if isinstance(data, list) else [data] if not any(item.get("assigned_users") for item in items): @@ -430,8 +446,8 @@ def _resolve_assigned_users( continue project_id = item["project"] if project_id not in membership_maps: - memberships = to_jsonable(client.projects.get(project_id).list_memberships(**_paginated({}))) - membership_maps[project_id] = {m["user"]: m.get("full_name") for m in memberships} + wanted = {uid for it in items if it.get("project") == project_id for uid in it.get("assigned_users") or []} + membership_maps[project_id] = _membership_names(client.projects.get(project_id), wanted) name_map = membership_maps[project_id] item["assigned_users_extra_info"] = [ {"id": uid, "full_name_display": name_map.get(uid)} for uid in assigned_users @@ -1081,12 +1097,13 @@ def update_work_items( the sprint-rollover workflow this tool targets. Use `update_wiki_page` directly. """ results: list[dict[str, Any]] = [] + proj = _resolve_project(project) if updates else None for item in updates: try: entity_type = item["entity_type"] ref = item["ref"] fields = item["fields"] - resource = _get_by_ref(entity_type, project, ref) + resource = _get_by_ref_in(proj, entity_type, ref) patch_fields = _with_version(resource, fields) resource.patch(list(patch_fields.keys()), **patch_fields) if return_representation == "full": diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index dd29ef0..3f49427 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2149,3 +2149,32 @@ def test_update_work_items_keeps_an_explicit_version(mock_get_client): ) mock_resource.patch.assert_called_once_with(["status", "version"], status=437, version=2) + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_resolves_project_once(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_task_by_ref.return_value = MagicMock(id=1) + mock_client.tasks.get.return_value = {"id": 1} + mock_get_client.return_value = mock_client + + updates = [{"entity_type": "task", "ref": n, "fields": {"subject": "x"}} for n in (1, 2, 3)] + server.update_work_items(1, updates) + + assert mock_client.projects.get.call_count == 1 + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_assigned_users_pages_through_memberships(mock_get_client): + first = [{"user": n, "full_name": f"U{n}"} for n in range(1, 101)] + second = [{"user": 101, "full_name": "U101"}] + mock_project = MagicMock() + mock_project.list_memberships.side_effect = [first, second] + mock_get_client.return_value.projects.get.return_value = mock_project + + result = server._resolve_assigned_users({"project": 1, "assigned_users": [101]}) + + assert result["assigned_users_extra_info"] == [{"id": 101, "full_name_display": "U101"}] + assert mock_project.list_memberships.call_count == 2 diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index e21eca7..34c86b2 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -466,3 +466,13 @@ def test_apply_payload_fields_are_not_media_stripped_by_default_from_payload(): assert kept == {"owner_extra_info": {"photo": "u"}} assert stripped == {"owner_extra_info": {}} + + +def test_apply_payload_compact_strip_media_false_keeps_nested_media(): + data = {"id": 1, "owner_extra_info": {"id": 2, "full_name_display": "A", "photo": "u", "big_photo": "b"}} + + kept = apply_payload(data, "issue", payload="compact", strip_media=False) + default = apply_payload(data, "issue", payload="compact") + + assert kept["owner_extra_info"] == {"id": 2, "full_name_display": "A", "photo": "u", "big_photo": "b"} + assert default["owner_extra_info"] == {"id": 2, "full_name_display": "A"} From 58418a00608bdcba2c83e91b4c86de62ace74e65 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:19:27 +0200 Subject: [PATCH 4/7] fix(models): bypass attribute cache when deriving default custom-attribute version Co-Authored-By: Claude Sonnet 5.5 --- taiga/models/models.py | 4 +++- tests/test_custom_attributes.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/taiga/models/models.py b/taiga/models/models.py index 31ee35f..626274b 100644 --- a/taiga/models/models.py +++ b/taiga/models/models.py @@ -51,7 +51,9 @@ def set_attribute(self, id, value, version=None): # noqa: A002 :param value: value of the attribute :param version: version of the custom-attributes-values resource (default: its current version) """ - attributes = self._get_attributes(cache=True) + # An omitted version is derived from the fetched values, so it must be fresh: a cached + # copy from an earlier write would carry a stale version after an external edit. + attributes = self._get_attributes(cache=version is not None) if version is None: version = attributes.get("version", 1) formatted_id = f"{id}" diff --git a/tests/test_custom_attributes.py b/tests/test_custom_attributes.py index 6d25baa..c39a3b9 100644 --- a/tests/test_custom_attributes.py +++ b/tests/test_custom_attributes.py @@ -60,6 +60,19 @@ def test_set_attribute_keeps_an_explicit_version(self, mock_requestmaker_patch, issue.set_attribute(1, 13, version=9) self.assertEqual(mock_requestmaker_patch.call_args.kwargs["payload"]["version"], 9) + @patch("taiga.requestmaker.RequestMaker.get") + @patch("taiga.requestmaker.RequestMaker.patch") + def test_set_attribute_bypasses_the_cache_to_derive_a_default_version( + self, mock_requestmaker_patch, mock_requestmaker_get + ): + stale = create_mock_json("tests/resources/issue_customattr_success.json") + mock_requestmaker_get.return_value = MockResponse(200, stale) + mock_requestmaker_patch.return_value = MockResponse(200, stale) + rm = RequestMaker("/api/v1", "fakehost", "faketoken") + issue = Issue(rm, id=1, project=1) + issue.set_attribute(1, 13) + self.assertFalse(mock_requestmaker_get.call_args.kwargs["cache"]) + @patch("taiga.requestmaker.RequestMaker.get") def test_get_issue_custom_attributes(self, mock_requestmaker_get): mock_requestmaker_get.return_value = MockResponse( From e51d25bef4ebaff1e551d9c963d535e945141171 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:22:31 +0200 Subject: [PATCH 5/7] fix(mcp): keep batch readback failures distinct from write failures; narrow read-reduction scope Co-Authored-By: Claude Sonnet 5.5 --- changes/+mcp-payload-reduction.feature | 2 +- taiga/mcp_server/server.py | 28 +++++++++++++++++++++----- tests/test_mcp_server.py | 18 +++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/changes/+mcp-payload-reduction.feature b/changes/+mcp-payload-reduction.feature index 3de3382..df56a6b 100644 --- a/changes/+mcp-payload-reduction.feature +++ b/changes/+mcp-payload-reduction.feature @@ -1 +1 @@ -Add opt-in payload-reduction parameters (`payload`, `fields`, `strip_media`, `expand`, `resolve_assigned_users`, `strict_filters`) to the MCP server's read tools, shrinking oversized responses on request while leaving default behaviour byte-for-byte unchanged. +Add opt-in payload-reduction parameters (`payload`, `fields`, `strip_media`, `expand`, `resolve_assigned_users`, `strict_filters`) to the MCP server's full-resource `list_*`/`get_*` tools, shrinking oversized responses on request while leaving default behaviour byte-for-byte unchanged. The history and custom-attribute-value read tools are not covered. diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index bfb78e7..223622d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -1095,6 +1095,10 @@ def update_work_items( Wiki pages are not supported here - they have no per-project ref number, and aren't part of the sprint-rollover workflow this tool targets. Use `update_wiki_page` directly. + + If the write succeeds but the follow-up re-fetch for `return_representation` fails, the row is + `{"status": "updated", "entity_type": ..., "ref": ..., "id": ..., "readback_error": ""}` + - the change was applied, so do not retry it. """ results: list[dict[str, Any]] = [] proj = _resolve_project(project) if updates else None @@ -1106,23 +1110,37 @@ def update_work_items( resource = _get_by_ref_in(proj, entity_type, ref) patch_fields = _with_version(resource, fields) resource.patch(list(patch_fields.keys()), **patch_fields) + except Exception as exc: # A single bad item must not abort the rest of the batch. + results.append(_batch_error(item, exc)) + continue + try: if return_representation == "full": client_attr = getattr(get_client(), _ENTITY_ATTR[entity_type]) results.append(to_jsonable(client_attr.get(resource.id))) else: results.append(_represent(resource, fields, return_representation)) - except Exception as exc: # A single bad item must not abort the rest of the batch. + except Exception as exc: # The write already succeeded: report it as such, not as a failure. results.append( { - "status": "error", - "entity_type": item.get("entity_type"), - "ref": item.get("ref"), - "error": f"{type(exc).__name__}: {exc}", + "status": "updated", + "entity_type": entity_type, + "ref": ref, + "id": resource.id, + "readback_error": f"{type(exc).__name__}: {exc}", } ) return results +def _batch_error(item: dict[str, Any], exc: Exception) -> dict[str, Any]: + return { + "status": "error", + "entity_type": item.get("entity_type"), + "ref": item.get("ref"), + "error": f"{type(exc).__name__}: {exc}", + } + + # --- Milestones (sprints) ----------------------------------------------------------------- diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3f49427..e717c58 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2178,3 +2178,21 @@ def test_resolve_assigned_users_pages_through_memberships(mock_get_client): assert result["assigned_users_extra_info"] == [{"id": 101, "full_name_display": "U101"}] assert mock_project.list_memberships.call_count == 2 + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_readback_failure_is_not_reported_as_write_failure(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_task = MagicMock(id=7) + mock_project.get_task_by_ref.return_value = mock_task + mock_client.tasks.get.side_effect = RuntimeError("timeout") + mock_get_client.return_value = mock_client + + result = server.update_work_items(1, [{"entity_type": "task", "ref": 3, "fields": {"subject": "x"}}]) + + mock_task.patch.assert_called_once() + assert result == [ + {"status": "updated", "entity_type": "task", "ref": 3, "id": 7, "readback_error": "RuntimeError: timeout"} + ] From 345a59fb415a39060def0f041e344bb2405510bd Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:25:30 +0200 Subject: [PATCH 6/7] fix(mcp): resolve the batch project lazily inside the per-item handler Co-Authored-By: Claude Sonnet 5.5 --- taiga/mcp_server/server.py | 4 +++- tests/test_mcp_server.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 223622d..cb3b42d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -1101,12 +1101,14 @@ def update_work_items( - the change was applied, so do not retry it. """ results: list[dict[str, Any]] = [] - proj = _resolve_project(project) if updates else None + proj = None for item in updates: try: entity_type = item["entity_type"] ref = item["ref"] fields = item["fields"] + if proj is None: # Resolved lazily so a lookup failure becomes an error row, not an abort. + proj = _resolve_project(project) resource = _get_by_ref_in(proj, entity_type, ref) patch_fields = _with_version(resource, fields) resource.patch(list(patch_fields.keys()), **patch_fields) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index e717c58..bd61bd1 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2196,3 +2196,21 @@ def test_update_work_items_readback_failure_is_not_reported_as_write_failure(moc assert result == [ {"status": "updated", "entity_type": "task", "ref": 3, "id": 7, "readback_error": "RuntimeError: timeout"} ] + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_project_lookup_failure_is_a_per_item_error(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_task = MagicMock(id=5) + mock_project.get_task_by_ref.return_value = mock_task + mock_client.projects.get.side_effect = [RuntimeError("503"), mock_project] + mock_client.tasks.get.return_value = {"id": 5} + mock_get_client.return_value = mock_client + + updates = [{"entity_type": "task", "ref": n, "fields": {"subject": "x"}} for n in (1, 2)] + result = server.update_work_items(1, updates) + + assert result[0]["status"] == "error" + assert result[1] == {"id": 5} + assert mock_client.projects.get.call_count == 2 From 6a1f9c5539d39cc649c8490d77ea11c0d41024b3 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:34:37 +0200 Subject: [PATCH 7/7] docs(mcp): document the update_work_items readback_error row Co-Authored-By: Claude Sonnet 5.5 --- docs/mcp.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index 2da8017..b1869c8 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -451,7 +451,12 @@ Available tools back or blocks any other item. Returns one result row per input item, in the same order (zip ``updates`` with the result to match them up); a failed item's row is - ``{"status": "error", "entity_type", "ref", "error"}``. Wiki pages + ``{"status": "error", "entity_type", "ref", "error"}``. If the write + succeeded but the follow-up re-fetch needed for + ``return_representation`` failed, the row is instead + ``{"status": "updated", "entity_type", "ref", "id", + "readback_error"}`` - the change **was applied**, so don't retry it; + re-read the item to see its current state. Wiki pages aren't supported here (no per-project ``ref``) - use ``update_wiki_page`` directly. As with the single-item tools, a missing ``version`` in an item's