From 0431a2cff6326580620b51caece6af54a0ca4ffa Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 12 Sep 2026 10:09:15 +0200 Subject: [PATCH 01/58] Release 2.0.0b2 --- pyproject.toml | 2 +- taiga/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e81f1d1..5548a94 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 = "1.3.4.dev1" +current_version = "2.0.0b2" parse = """(?x) (?P[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index c802f9e..770143d 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "1.3.4.dev1" +__version__ = "2.0.0b2" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] From e7dc9c51b404dcedd1945540a6416d9b0d3c2c6f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 14:48:17 +0200 Subject: [PATCH 02/58] fix(mcp): make project optional on list_milestones/list_wiki_pages Neither Milestones.list() nor WikiPages.list() require project at the client/API level (confirmed live against taiga.nephila.it), unlike project-scoped tools such as search or the by_ref lookups. Bring list_milestones and list_wiki_pages in line with the sibling list_user_stories/list_tasks/list_issues/list_epics pattern, which already treat project as optional. Co-Authored-By: Claude Sonnet 5 --- docs/mcp.rst | 4 ++-- taiga/mcp_server/server.py | 16 ++++++++-------- tests/test_mcp_server.py | 28 ++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index 3be3a61..e6ea549 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -238,10 +238,10 @@ Available tools response), not a ref. ``list_milestones``, ``get_milestone``, ``create_milestone``, ``delete_milestone`` - Manage milestones (sprints). + Manage milestones (sprints), optionally scoped to a project. ``list_wiki_pages``, ``get_wiki_page``, ``create_wiki_page``, ``update_wiki_page`` - Manage wiki pages. + Manage wiki pages, optionally scoped to a project. .. tip:: Call ``get_project`` first when creating or updating an entity - it returns every status/priority/severity/points id valid for that diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 974066d..6c881dd 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -615,15 +615,15 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An @mcp.tool() -def list_milestones(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List milestones (sprints) of a project. +def list_milestones(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List milestones (sprints), optionally scoped to a project. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. """ - pid = _resolve_project_id(project) query = dict(filters or {}) - query["project"] = pid + if project is not None: + query["project"] = _resolve_project_id(project) return to_jsonable(get_client().milestones.list(**_paginated(query))) @@ -657,15 +657,15 @@ def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool() -def list_wiki_pages(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """List wiki pages of a project. +def list_wiki_pages(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """List wiki pages, optionally scoped to a project. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. """ - pid = _resolve_project_id(project) query = dict(filters or {}) - query["project"] = pid + if project is not None: + query["project"] = _resolve_project_id(project) return to_jsonable(get_client().wikipages.list(**_paginated(query))) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 17e767b..04cd464 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -981,7 +981,19 @@ def test_link_epic_user_story_by_id(mock_get_client): @patch("taiga.mcp_server.server.get_client") -def test_list_milestones(mock_get_client): +def test_list_milestones_no_project(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_milestones() + + mock_client.milestones.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_milestones_with_project(mock_get_client): mock_client = MagicMock() mock_client.milestones.list.return_value = [{"id": 1}] mock_get_client.return_value = mock_client @@ -1031,7 +1043,19 @@ def test_delete_milestone(mock_get_client): @patch("taiga.mcp_server.server.get_client") -def test_list_wiki_pages(mock_get_client): +def test_list_wiki_pages_no_project(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_wiki_pages() + + mock_client.wikipages.list.assert_called_once_with(page=1, page_size=100) + assert result == [{"id": 1}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_wiki_pages_with_project(mock_get_client): mock_client = MagicMock() mock_client.wikipages.list.return_value = [{"id": 1}] mock_get_client.return_value = mock_client From 1650b3bdea238d526aa058ee0917c84053afd40e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 14:48:24 +0200 Subject: [PATCH 03/58] docs: add evaluation report and activity log for project-param fix Co-Authored-By: Claude Sonnet 5 --- .../2026-09-14-mcp-optional-project-param.md | 40 +++++++++++++++++++ .../2026-09-14-mcp-optional-project-param.md | 23 +++++++++++ 2 files changed, 63 insertions(+) create mode 100644 artifacts/activity-log/2026-09-14-mcp-optional-project-param.md create mode 100644 artifacts/evaluations/2026-09-14-mcp-optional-project-param.md diff --git a/artifacts/activity-log/2026-09-14-mcp-optional-project-param.md b/artifacts/activity-log/2026-09-14-mcp-optional-project-param.md new file mode 100644 index 0000000..13ebe66 --- /dev/null +++ b/artifacts/activity-log/2026-09-14-mcp-optional-project-param.md @@ -0,0 +1,40 @@ +## 2026-09-14 — Made `project` optional on `list_milestones`/`list_wiki_pages` in the MCP server +**What:** Audited every `project`-taking tool in `taiga/mcp_server/server.py` against +what the underlying python-taiga client/API actually requires. `list_milestones` and +`list_wiki_pages` required `project` but the underlying `Milestones.list()` / +`WikiPages.list()` calls are unconstrained `ListResource.list(**queryparams)` — no +required args. Confirmed live against `https://taiga.nephila.it` (auth via +TAIGA_USER/TAIGA_PASSWORD): both list calls succeed with no `project` filter, +returning cross-project results (1246 milestones, 94 wiki pages). Made `project` +optional on both, mirroring the existing `list_user_stories`/`list_tasks`/ +`list_issues`/`list_epics` pattern (`project: str | int | None = None`, added to the +query only when given). Everything else audited (ref-based `by_ref` lookups, +`search`, `list_memberships`, all `create_*` calls) genuinely requires `project` at +the API level and was left untouched. Updated `docs/mcp.rst` for the two changed +tools to match. +**Why:** User request via `/nephila-flow`: "verify in MCP server, where project +parameter is really required by the underlying API. when not required, make it +optional." +**Decisions:** Treated this as a lightweight adaptation of the flow (agreed with the +user up front) rather than the full 11-step lifecycle — no formal brainstorming/spec +or written plan doc, since it's a small, well-scoped verification+fix, not a new +feature; kept branch/TDD/review/wrap-up/commit-review steps as normal. Skipped the +towncrier changelog fragment (CONTRIBUTING.rst requires fragment names to reference +a real issue number; none exists for this minor fix and GitHub MCP wasn't connected +to create/look one up) — user's explicit call when asked. +**Agent usage:** + +| Stage | Agent/skill | Tokens | Time | +|---|---|---|---| +| Review | superpowers:requesting-code-review (general-purpose subagent) | ~79k | ~70s | + +**Considered & dropped:** Re-verifying every "genuinely required" tool live against +the real API too — judged unnecessary since those are structurally required by the +client library's own signatures/hardcoded query params (`by_ref` endpoint, `search`, +`Project.list_memberships`), not just conventionally passed; the review subagent +independently spot-checked that reasoning against `taiga/client.py` and +`taiga/models/models.py` and confirmed it. +**Follow-ups:** None outstanding — review verdict was "Ready to merge: Yes" with no +Critical/Important issues. +**Refs:** Branch `issue/mcp-optional-project-param`, off `feature/issue-267-add-mcp`. +Eval: 94% — artifacts/evaluations/2026-09-14-mcp-optional-project-param.md diff --git a/artifacts/evaluations/2026-09-14-mcp-optional-project-param.md b/artifacts/evaluations/2026-09-14-mcp-optional-project-param.md new file mode 100644 index 0000000..0d6013a --- /dev/null +++ b/artifacts/evaluations/2026-09-14-mcp-optional-project-param.md @@ -0,0 +1,23 @@ +# Evaluation — mcp-optional-project-param + +- **Date:** 2026-09-14 +- **Branch:** issue/mcp-optional-project-param (working tree, uncommitted, vs HEAD 05c846f) +- **Task:** verify where `project` is really required by the underlying Taiga API in the MCP server; make it optional where it isn't +- **Coverage:** full — both changed files (`taiga/mcp_server/server.py`, `tests/test_mcp_server.py`) read in full; whole audit of every `project`-taking tool in `server.py` performed and independently spot-checked by a review subagent + +## Scores +| Dimension | Score | Weight | Key evidence | +|---|---|---|---| +| Functionality | 5 | 20 | Verified live against `https://taiga.nephila.it`: `milestones.list()`/`wikipages.list()` succeed with no `project` (1246/94 items); fix mirrors sibling `list_user_stories`/`list_tasks`/`list_issues`/`list_epics` exactly (server.py:294-303 vs new list_milestones/list_wiki_pages) | +| Testing | 5 | 15 | RED confirmed (`TypeError: missing 1 required positional argument: 'project'`) before GREEN; `_no_project`/`_with_project` pairs added for both functions; real `assert_called_once_with(...)` assertions, not tautological mocks; full suite 340/340 pass | +| Security | 5 | 15 | No new trust-boundary input handling beyond existing `_resolve_project_id`; no secrets, no injection-prone construction | +| Code quality & best practices | 5 | 15 | `ruff check`, `black --check`, `isort --check` all clean; change is a byte-for-byte pattern match to existing sibling functions, no dead code | +| Maintainability & flexibility | 5 | 15 | Single-purpose, minimal diff (16 lines in server.py), no new coupling | +| Error handling | 4 | 10 | No new failure paths introduced; `project=0` edge case correctly handled via `is not None` (not truthiness) check, verified by review subagent | +| Documentation | 3 | 10 | Docstrings on both changed functions updated ("optionally scoped to a project"); `docs/mcp.rst` entries for `list_milestones`/`list_wiki_pages` not yet updated and no towncrier changelog fragment added — flagged by review as the only outstanding minor items, to be closed by doc-sync next | + +## Recommendations +- Documentation: update `docs/mcp.rst`'s `list_milestones`/`list_wiki_pages` lines to mention optional project scoping, and add a towncrier changelog fragment — handled next via the doc-sync step. + +## Total +**94%** — Correct, well-tested, low-risk fix; only the not-yet-run doc-sync pass keeps Documentation below top marks. From 4536dc54f1d4a8d0b86b2ef8c91b50c9feccdc13 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 14:55:45 +0200 Subject: [PATCH 04/58] feat(mcp): add include_user_stories option to list_milestones/get_milestone Milestone.user_stories is always fully expanded by python-taiga's parser, making the embedded field potentially large. Add include_user_stories (default True, preserving current output) to both tools so callers can opt into a trimmed response with that key stripped. Co-Authored-By: Claude Sonnet 5 --- taiga/mcp_server/server.py | 29 ++++++++++++++++++++----- tests/test_mcp_server.py | 44 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 6c881dd..d72bcc0 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -614,23 +614,42 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An # --- Milestones (sprints) ----------------------------------------------------------------- +def _strip_user_stories(data: Any) -> Any: + """Drop the 'user_stories' key from one or more serialized milestone dicts.""" + for item in data if isinstance(data, list) else [data]: + item.pop("user_stories", None) + return data + + @mcp.tool() -def list_milestones(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_milestones( + project: str | int | None = None, + filters: dict[str, Any] | None = None, + include_user_stories: bool = True, +) -> list[dict[str, Any]]: """List milestones (sprints), optionally scoped to a project. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + Each milestone embeds its full `user_stories`; pass `include_user_stories=False` + to strip that (potentially large) field from every returned milestone. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().milestones.list(**_paginated(query))) + result = to_jsonable(get_client().milestones.list(**_paginated(query))) + return result if include_user_stories else _strip_user_stories(result) @mcp.tool() -def get_milestone(id: int) -> dict[str, Any]: # noqa: A002 - """Get a milestone by id.""" - return to_jsonable(get_client().milestones.get(id)) +def get_milestone(id: int, include_user_stories: bool = True) -> dict[str, Any]: # noqa: A002 + """Get a milestone by id. + + The milestone embeds its full `user_stories`; pass `include_user_stories=False` + to strip that (potentially large) field from the returned milestone. + """ + result = to_jsonable(get_client().milestones.get(id)) + return result if include_user_stories else _strip_user_stories(result) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 04cd464..f411caa 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1004,6 +1004,28 @@ def test_list_milestones_with_project(mock_get_client): assert result == [{"id": 1}] +@patch("taiga.mcp_server.server.get_client") +def test_list_milestones_includes_user_stories_by_default(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.list.return_value = [{"id": 1, "user_stories": [{"id": 10}]}] + mock_get_client.return_value = mock_client + + result = server.list_milestones() + + assert result == [{"id": 1, "user_stories": [{"id": 10}]}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_milestones_excludes_user_stories_when_disabled(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.list.return_value = [{"id": 1, "user_stories": [{"id": 10}]}] + mock_get_client.return_value = mock_client + + result = server.list_milestones(include_user_stories=False) + + assert result == [{"id": 1}] + + @patch("taiga.mcp_server.server.get_client") def test_get_milestone(mock_get_client): mock_client = MagicMock() @@ -1016,6 +1038,28 @@ def test_get_milestone(mock_get_client): assert result == {"id": 1} +@patch("taiga.mcp_server.server.get_client") +def test_get_milestone_includes_user_stories_by_default(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.get.return_value = {"id": 1, "user_stories": [{"id": 10}]} + mock_get_client.return_value = mock_client + + result = server.get_milestone(1) + + assert result == {"id": 1, "user_stories": [{"id": 10}]} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_milestone_excludes_user_stories_when_disabled(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.get.return_value = {"id": 1, "user_stories": [{"id": 10}]} + mock_get_client.return_value = mock_client + + result = server.get_milestone(1, include_user_stories=False) + + assert result == {"id": 1} + + @patch("taiga.mcp_server.server.get_client") def test_create_milestone(mock_get_client): mock_client = MagicMock() From bbf8395a9a33eb4d3c6c0b85b81fa04b8e96b0f7 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 14:57:36 +0200 Subject: [PATCH 05/58] refactor(mcp): narrow _strip_user_stories type hint, add direct unit tests Minor code-review follow-ups: replace the Any -> Any signature with the actual dict[str, Any] | list[dict[str, Any]] shape it's always called with, and add unit tests exercising the helper directly (dict, list, and no-op-when-absent cases) rather than only through list_milestones/ get_milestone. Co-Authored-By: Claude Sonnet 5 --- taiga/mcp_server/server.py | 4 +++- tests/test_mcp_server.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index d72bcc0..2f213b9 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -614,7 +614,9 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An # --- Milestones (sprints) ----------------------------------------------------------------- -def _strip_user_stories(data: Any) -> Any: +def _strip_user_stories( + data: dict[str, Any] | list[dict[str, Any]], +) -> dict[str, Any] | list[dict[str, Any]]: """Drop the 'user_stories' key from one or more serialized milestone dicts.""" for item in data if isinstance(data, list) else [data]: item.pop("user_stories", None) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index f411caa..80b83ad 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -980,6 +980,19 @@ def test_link_epic_user_story_by_id(mock_get_client): # --- Milestones ------------------------------------------------------------------------ +def test_strip_user_stories_removes_key_from_dict(): + assert server._strip_user_stories({"id": 1, "user_stories": [{"id": 10}]}) == {"id": 1} + + +def test_strip_user_stories_removes_key_from_each_item_in_list(): + data = [{"id": 1, "user_stories": []}, {"id": 2, "user_stories": [{"id": 10}]}] + assert server._strip_user_stories(data) == [{"id": 1}, {"id": 2}] + + +def test_strip_user_stories_no_op_when_key_absent(): + assert server._strip_user_stories({"id": 1}) == {"id": 1} + + @patch("taiga.mcp_server.server.get_client") def test_list_milestones_no_project(mock_get_client): mock_client = MagicMock() From f25992c3eb1ff8bf27e5e8277de9d53741a1bd7d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 15:02:00 +0200 Subject: [PATCH 06/58] docs(mcp): document include_user_stories flag on milestone tools Co-Authored-By: Claude Sonnet 5 --- docs/mcp.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index e6ea549..f26b204 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -237,8 +237,14 @@ Available tools you already hold the database id (for example from a prior tool response), not a ref. -``list_milestones``, ``get_milestone``, ``create_milestone``, ``delete_milestone`` - Manage milestones (sprints), optionally scoped to a project. +``list_milestones``, ``get_milestone`` + List/get milestones (sprints), optionally scoped to a project (``list_milestones`` + only). Each milestone embeds its full ``user_stories`` - pass + ``include_user_stories=False`` to strip that (potentially large) field from the + result. + +``create_milestone``, ``delete_milestone`` + Create/delete milestones (sprints). ``list_wiki_pages``, ``get_wiki_page``, ``create_wiki_page``, ``update_wiki_page`` Manage wiki pages, optionally scoped to a project. From aaef67ebcad1d05a4122e6714094a7289c76cc9f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 15:02:04 +0200 Subject: [PATCH 07/58] docs: add evaluation report and activity log for milestone user_stories toggle Co-Authored-By: Claude Sonnet 5 --- ...09-14-mcp-milestone-user-stories-toggle.md | 39 +++++++++++++++++++ ...09-14-mcp-milestone-user-stories-toggle.md | 23 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md create mode 100644 artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md diff --git a/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md b/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md new file mode 100644 index 0000000..2d919ba --- /dev/null +++ b/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md @@ -0,0 +1,39 @@ +## 2026-09-14 — Added `include_user_stories` toggle to `list_milestones`/`get_milestone` +**What:** Added `include_user_stories: bool = True` to the MCP server's +`list_milestones` and `get_milestone` tools. `Milestone.user_stories` is always +fully expanded by python-taiga's parser into complete `UserStory` objects, which +`to_jsonable()` then serializes in full — so the embedded field can be large. +When `include_user_stories=False`, a new `_strip_user_stories()` helper pops that +key from each returned milestone dict (handles both the list shape from +`list_milestones` and the single-dict shape from `get_milestone`). Default `True` +preserves today's output exactly for existing callers. Updated `docs/mcp.rst`, +splitting the milestone tools' doc entry so the two changed tools get their own +line mentioning the flag. +**Why:** User request via `/nephila-flow`: "add to +taiga.mcp_server.server.list_milestones the option to return the full taiga +response for the given endpoint or to remove user_stories attribute from returned +data." +**Decisions:** Classified as "bounded" per superpowers:brainstorming (existing +tool, new flag) — short in-chat design instead of a written spec/plan doc, then +straight to TDD implementation; matches the same-day precedent from +[[2026-09-14-mcp-optional-project-param]]. Confirmed with the user that "full +Taiga response" meant one toggle (embed vs. strip `user_stories`), not a +raw/unparsed API passthrough. Extended the flag to `get_milestone` too (not just +`list_milestones` as literally asked) since it shares the identical +embedded-`user_stories` issue — user's explicit call when asked. Default `True` +chosen over `False` to avoid a breaking change to existing callers' output shape. +**Agent usage:** + +| Stage | Agent/skill | Tokens | Time | +|---|---|---|---| +| Review | superpowers:requesting-code-review (general-purpose subagent) | ~70k | ~60s | + +**Considered & dropped:** N/A — single clear approach, no alternatives seriously +weighed beyond the toggle-meaning clarification above. +**Follow-ups:** Review flagged two Minor nits (helper's `Any` type hint too broad; +no direct unit test of the helper) — both applied immediately as a follow-up commit +(narrowed the type hint, added 3 direct unit tests) rather than deferred. +**Refs:** Branch `issue/mcp-milestone-user-stories-toggle`, off +`issue/mcp-optional-project-param` (itself off `feature/issue-267-add-mcp`), not yet +merged/pushed. +Eval: 94% — artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md diff --git a/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md b/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md new file mode 100644 index 0000000..883ed3c --- /dev/null +++ b/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md @@ -0,0 +1,23 @@ +# Evaluation — mcp-milestone-user-stories-toggle + +- **Date:** 2026-09-14 +- **Branch:** issue/mcp-milestone-user-stories-toggle (vs issue/mcp-optional-project-param, 2 commits: 0b91d67, 26404b5) +- **Task:** add `include_user_stories` option to `list_milestones`/`get_milestone` to strip the embedded `user_stories` field on request +- **Coverage:** full — both changed files read in full; approved short in-chat design (bounded path) used as the spec + +## Scores +| Dimension | Score | Weight | Key evidence | +|---|---|---|---| +| Functionality | 5 | 20 | Implementation matches the approved design exactly (default `True`, trailing optional param, `_strip_user_stories` helper); review subagent independently verified backward compatibility with old-style positional/keyword calls | +| Testing | 5 | 15 | RED confirmed (`TypeError: unexpected keyword argument`) before GREEN; 4 include/exclude × list/get cases plus 3 direct unit tests of `_strip_user_stories` (dict, list, no-op-when-absent) added post-review; full suite 347/347 pass | +| Security | 5 | 15 | Pure in-memory dict transformation, no new trust-boundary input handling | +| Code quality & best practices | 5 | 15 | `ruff`/`black` clean; helper type hint narrowed from `Any` to `dict[str, Any] \| list[dict[str, Any]]` per review; matches sibling docstring style | +| Maintainability & flexibility | 5 | 15 | Single-purpose `_strip_user_stories` helper, two clear call sites, no coupling introduced | +| Error handling | 4 | 10 | `.pop(key, None)` correctly no-ops when `user_stories` is absent; no new failure paths | +| Documentation | 3 | 10 | Docstrings on both changed tools updated; `docs/mcp.rst:240-241` still documents the milestone tools as one undifferentiated group with no mention of the new flag — to be closed by doc-sync next | + +## Recommendations +- Documentation: split `list_milestones`/`get_milestone` out from `create_milestone`/`delete_milestone` in `docs/mcp.rst` and mention `include_user_stories` — handled next via doc-sync. + +## Total +**94%** — Precise, well-tested, fully backward-compatible change with review-driven polish already applied; only the pending doc-sync pass keeps Documentation below top marks. From 63e338902bf67964f6c5c60d83365162755afdc7 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 14 Sep 2026 15:03:29 +0200 Subject: [PATCH 08/58] Release 2.0.0b3 --- pyproject.toml | 2 +- taiga/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5548a94..568e42c 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.0b2" +current_version = "2.0.0b3" parse = """(?x) (?P[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index 770143d..6351f31 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "2.0.0b2" +__version__ = "2.0.0b3" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] From 29960d27e2ad5db6f27829a73c9e4d86dfbedcb1 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 13:26:08 +0200 Subject: [PATCH 09/58] chore: remove files from git --- ...09-14-mcp-milestone-user-stories-toggle.md | 39 ------------------ .../2026-09-14-mcp-optional-project-param.md | 40 ------------------- ...09-14-mcp-milestone-user-stories-toggle.md | 23 ----------- .../2026-09-14-mcp-optional-project-param.md | 23 ----------- 4 files changed, 125 deletions(-) delete mode 100644 artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md delete mode 100644 artifacts/activity-log/2026-09-14-mcp-optional-project-param.md delete mode 100644 artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md delete mode 100644 artifacts/evaluations/2026-09-14-mcp-optional-project-param.md diff --git a/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md b/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md deleted file mode 100644 index 2d919ba..0000000 --- a/artifacts/activity-log/2026-09-14-mcp-milestone-user-stories-toggle.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2026-09-14 — Added `include_user_stories` toggle to `list_milestones`/`get_milestone` -**What:** Added `include_user_stories: bool = True` to the MCP server's -`list_milestones` and `get_milestone` tools. `Milestone.user_stories` is always -fully expanded by python-taiga's parser into complete `UserStory` objects, which -`to_jsonable()` then serializes in full — so the embedded field can be large. -When `include_user_stories=False`, a new `_strip_user_stories()` helper pops that -key from each returned milestone dict (handles both the list shape from -`list_milestones` and the single-dict shape from `get_milestone`). Default `True` -preserves today's output exactly for existing callers. Updated `docs/mcp.rst`, -splitting the milestone tools' doc entry so the two changed tools get their own -line mentioning the flag. -**Why:** User request via `/nephila-flow`: "add to -taiga.mcp_server.server.list_milestones the option to return the full taiga -response for the given endpoint or to remove user_stories attribute from returned -data." -**Decisions:** Classified as "bounded" per superpowers:brainstorming (existing -tool, new flag) — short in-chat design instead of a written spec/plan doc, then -straight to TDD implementation; matches the same-day precedent from -[[2026-09-14-mcp-optional-project-param]]. Confirmed with the user that "full -Taiga response" meant one toggle (embed vs. strip `user_stories`), not a -raw/unparsed API passthrough. Extended the flag to `get_milestone` too (not just -`list_milestones` as literally asked) since it shares the identical -embedded-`user_stories` issue — user's explicit call when asked. Default `True` -chosen over `False` to avoid a breaking change to existing callers' output shape. -**Agent usage:** - -| Stage | Agent/skill | Tokens | Time | -|---|---|---|---| -| Review | superpowers:requesting-code-review (general-purpose subagent) | ~70k | ~60s | - -**Considered & dropped:** N/A — single clear approach, no alternatives seriously -weighed beyond the toggle-meaning clarification above. -**Follow-ups:** Review flagged two Minor nits (helper's `Any` type hint too broad; -no direct unit test of the helper) — both applied immediately as a follow-up commit -(narrowed the type hint, added 3 direct unit tests) rather than deferred. -**Refs:** Branch `issue/mcp-milestone-user-stories-toggle`, off -`issue/mcp-optional-project-param` (itself off `feature/issue-267-add-mcp`), not yet -merged/pushed. -Eval: 94% — artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md diff --git a/artifacts/activity-log/2026-09-14-mcp-optional-project-param.md b/artifacts/activity-log/2026-09-14-mcp-optional-project-param.md deleted file mode 100644 index 13ebe66..0000000 --- a/artifacts/activity-log/2026-09-14-mcp-optional-project-param.md +++ /dev/null @@ -1,40 +0,0 @@ -## 2026-09-14 — Made `project` optional on `list_milestones`/`list_wiki_pages` in the MCP server -**What:** Audited every `project`-taking tool in `taiga/mcp_server/server.py` against -what the underlying python-taiga client/API actually requires. `list_milestones` and -`list_wiki_pages` required `project` but the underlying `Milestones.list()` / -`WikiPages.list()` calls are unconstrained `ListResource.list(**queryparams)` — no -required args. Confirmed live against `https://taiga.nephila.it` (auth via -TAIGA_USER/TAIGA_PASSWORD): both list calls succeed with no `project` filter, -returning cross-project results (1246 milestones, 94 wiki pages). Made `project` -optional on both, mirroring the existing `list_user_stories`/`list_tasks`/ -`list_issues`/`list_epics` pattern (`project: str | int | None = None`, added to the -query only when given). Everything else audited (ref-based `by_ref` lookups, -`search`, `list_memberships`, all `create_*` calls) genuinely requires `project` at -the API level and was left untouched. Updated `docs/mcp.rst` for the two changed -tools to match. -**Why:** User request via `/nephila-flow`: "verify in MCP server, where project -parameter is really required by the underlying API. when not required, make it -optional." -**Decisions:** Treated this as a lightweight adaptation of the flow (agreed with the -user up front) rather than the full 11-step lifecycle — no formal brainstorming/spec -or written plan doc, since it's a small, well-scoped verification+fix, not a new -feature; kept branch/TDD/review/wrap-up/commit-review steps as normal. Skipped the -towncrier changelog fragment (CONTRIBUTING.rst requires fragment names to reference -a real issue number; none exists for this minor fix and GitHub MCP wasn't connected -to create/look one up) — user's explicit call when asked. -**Agent usage:** - -| Stage | Agent/skill | Tokens | Time | -|---|---|---|---| -| Review | superpowers:requesting-code-review (general-purpose subagent) | ~79k | ~70s | - -**Considered & dropped:** Re-verifying every "genuinely required" tool live against -the real API too — judged unnecessary since those are structurally required by the -client library's own signatures/hardcoded query params (`by_ref` endpoint, `search`, -`Project.list_memberships`), not just conventionally passed; the review subagent -independently spot-checked that reasoning against `taiga/client.py` and -`taiga/models/models.py` and confirmed it. -**Follow-ups:** None outstanding — review verdict was "Ready to merge: Yes" with no -Critical/Important issues. -**Refs:** Branch `issue/mcp-optional-project-param`, off `feature/issue-267-add-mcp`. -Eval: 94% — artifacts/evaluations/2026-09-14-mcp-optional-project-param.md diff --git a/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md b/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md deleted file mode 100644 index 883ed3c..0000000 --- a/artifacts/evaluations/2026-09-14-mcp-milestone-user-stories-toggle.md +++ /dev/null @@ -1,23 +0,0 @@ -# Evaluation — mcp-milestone-user-stories-toggle - -- **Date:** 2026-09-14 -- **Branch:** issue/mcp-milestone-user-stories-toggle (vs issue/mcp-optional-project-param, 2 commits: 0b91d67, 26404b5) -- **Task:** add `include_user_stories` option to `list_milestones`/`get_milestone` to strip the embedded `user_stories` field on request -- **Coverage:** full — both changed files read in full; approved short in-chat design (bounded path) used as the spec - -## Scores -| Dimension | Score | Weight | Key evidence | -|---|---|---|---| -| Functionality | 5 | 20 | Implementation matches the approved design exactly (default `True`, trailing optional param, `_strip_user_stories` helper); review subagent independently verified backward compatibility with old-style positional/keyword calls | -| Testing | 5 | 15 | RED confirmed (`TypeError: unexpected keyword argument`) before GREEN; 4 include/exclude × list/get cases plus 3 direct unit tests of `_strip_user_stories` (dict, list, no-op-when-absent) added post-review; full suite 347/347 pass | -| Security | 5 | 15 | Pure in-memory dict transformation, no new trust-boundary input handling | -| Code quality & best practices | 5 | 15 | `ruff`/`black` clean; helper type hint narrowed from `Any` to `dict[str, Any] \| list[dict[str, Any]]` per review; matches sibling docstring style | -| Maintainability & flexibility | 5 | 15 | Single-purpose `_strip_user_stories` helper, two clear call sites, no coupling introduced | -| Error handling | 4 | 10 | `.pop(key, None)` correctly no-ops when `user_stories` is absent; no new failure paths | -| Documentation | 3 | 10 | Docstrings on both changed tools updated; `docs/mcp.rst:240-241` still documents the milestone tools as one undifferentiated group with no mention of the new flag — to be closed by doc-sync next | - -## Recommendations -- Documentation: split `list_milestones`/`get_milestone` out from `create_milestone`/`delete_milestone` in `docs/mcp.rst` and mention `include_user_stories` — handled next via doc-sync. - -## Total -**94%** — Precise, well-tested, fully backward-compatible change with review-driven polish already applied; only the pending doc-sync pass keeps Documentation below top marks. diff --git a/artifacts/evaluations/2026-09-14-mcp-optional-project-param.md b/artifacts/evaluations/2026-09-14-mcp-optional-project-param.md deleted file mode 100644 index 0d6013a..0000000 --- a/artifacts/evaluations/2026-09-14-mcp-optional-project-param.md +++ /dev/null @@ -1,23 +0,0 @@ -# Evaluation — mcp-optional-project-param - -- **Date:** 2026-09-14 -- **Branch:** issue/mcp-optional-project-param (working tree, uncommitted, vs HEAD 05c846f) -- **Task:** verify where `project` is really required by the underlying Taiga API in the MCP server; make it optional where it isn't -- **Coverage:** full — both changed files (`taiga/mcp_server/server.py`, `tests/test_mcp_server.py`) read in full; whole audit of every `project`-taking tool in `server.py` performed and independently spot-checked by a review subagent - -## Scores -| Dimension | Score | Weight | Key evidence | -|---|---|---|---| -| Functionality | 5 | 20 | Verified live against `https://taiga.nephila.it`: `milestones.list()`/`wikipages.list()` succeed with no `project` (1246/94 items); fix mirrors sibling `list_user_stories`/`list_tasks`/`list_issues`/`list_epics` exactly (server.py:294-303 vs new list_milestones/list_wiki_pages) | -| Testing | 5 | 15 | RED confirmed (`TypeError: missing 1 required positional argument: 'project'`) before GREEN; `_no_project`/`_with_project` pairs added for both functions; real `assert_called_once_with(...)` assertions, not tautological mocks; full suite 340/340 pass | -| Security | 5 | 15 | No new trust-boundary input handling beyond existing `_resolve_project_id`; no secrets, no injection-prone construction | -| Code quality & best practices | 5 | 15 | `ruff check`, `black --check`, `isort --check` all clean; change is a byte-for-byte pattern match to existing sibling functions, no dead code | -| Maintainability & flexibility | 5 | 15 | Single-purpose, minimal diff (16 lines in server.py), no new coupling | -| Error handling | 4 | 10 | No new failure paths introduced; `project=0` edge case correctly handled via `is not None` (not truthiness) check, verified by review subagent | -| Documentation | 3 | 10 | Docstrings on both changed functions updated ("optionally scoped to a project"); `docs/mcp.rst` entries for `list_milestones`/`list_wiki_pages` not yet updated and no towncrier changelog fragment added — flagged by review as the only outstanding minor items, to be closed by doc-sync next | - -## Recommendations -- Documentation: update `docs/mcp.rst`'s `list_milestones`/`list_wiki_pages` lines to mention optional project scoping, and add a towncrier changelog fragment — handled next via the doc-sync step. - -## Total -**94%** — Correct, well-tested, low-risk fix; only the not-yet-run doc-sync pass keeps Documentation below top marks. From a1a83a9cf4a26575b8f97907f080136ad4fc11c4 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:20:11 +0200 Subject: [PATCH 10/58] feat(mcp): add strip_avatar_fields helper --- taiga/mcp_server/serialize.py | 15 ++++++++++ tests/test_mcp_server_serialize.py | 44 +++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index d6c7ca3..f3f0a5a 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -10,6 +10,7 @@ from ..models.base import InstanceResource _SKIPPED_ATTRS = {"requester"} +_AVATAR_KEYS = frozenset({"photo", "big_photo", "gravatar_id", "logo_small_url"}) def to_jsonable(value: Any) -> Any: @@ -25,3 +26,17 @@ def to_jsonable(value: Any) -> Any: if isinstance(value, (list, tuple)): return [to_jsonable(item) for item in value] return str(value) + + +def strip_avatar_fields(data: Any) -> Any: + """Recursively drop avatar/logo-URL keys from a jsonable dict/list structure. + + Removes `photo`, `big_photo`, `gravatar_id` and `logo_small_url` wherever they occur, + regardless of which resource type's `*_extra_info` block they came from - these key + names are never used for anything but a rotating-signature avatar/logo URL. + """ + if isinstance(data, dict): + return {key: strip_avatar_fields(val) for key, val in data.items() if key not in _AVATAR_KEYS} + if isinstance(data, list): + return [strip_avatar_fields(item) for item in data] + return data diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index fedff0d..5933a09 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -3,7 +3,7 @@ import datetime from unittest.mock import MagicMock -from taiga.mcp_server.serialize import to_jsonable +from taiga.mcp_server.serialize import strip_avatar_fields, to_jsonable from taiga.models.base import InstanceResource @@ -73,3 +73,45 @@ def test_to_jsonable_converts_plain_date_and_datetime_values(): "due_date": "2026-01-01", "finished_at": "2026-01-01T12:30:00+00:00", } + + +def test_strip_avatar_fields_removes_known_keys_from_nested_dict(): + data = { + "id": 1, + "owner_extra_info": { + "full_name_display": "Alice", + "photo": "https://example.com/a.png", + "big_photo": "https://example.com/a-big.png", + "gravatar_id": "abc123", + }, + } + + result = strip_avatar_fields(data) + + assert result == {"id": 1, "owner_extra_info": {"full_name_display": "Alice"}} + + +def test_strip_avatar_fields_removes_logo_small_url(): + data = {"project_extra_info": {"name": "Demo", "logo_small_url": "https://example.com/logo.png"}} + + result = strip_avatar_fields(data) + + assert result == {"project_extra_info": {"name": "Demo"}} + + +def test_strip_avatar_fields_recurses_into_list_of_dicts(): + data = [{"photo": "x", "id": 1}, {"photo": "y", "id": 2}] + + result = strip_avatar_fields(data) + + assert result == [{"id": 1}, {"id": 2}] + + +def test_strip_avatar_fields_no_op_when_no_avatar_keys_present(): + assert strip_avatar_fields({"id": 1, "subject": "hello"}) == {"id": 1, "subject": "hello"} + + +def test_strip_avatar_fields_passes_through_non_dict_non_list_values(): + assert strip_avatar_fields("hello") == "hello" + assert strip_avatar_fields(42) == 42 + assert strip_avatar_fields(None) is None From 8a6ad19a94b48118083e820a8fbbdc961e72a90d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:22:41 +0200 Subject: [PATCH 11/58] feat(mcp): add select_fields projection helper Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/serialize.py | 26 +++++++++++ tests/test_mcp_server_serialize.py | 69 +++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index f3f0a5a..a968ecf 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -40,3 +40,29 @@ def strip_avatar_fields(data: Any) -> Any: if isinstance(data, list): return [strip_avatar_fields(item) for item in data] return data + + +def select_fields(data: Any, paths: list[str]) -> Any: + """Project a jsonable dict/list down to only the requested (optionally dotted) paths.""" + if isinstance(data, list): + return [_select_from_item(item, paths) if isinstance(item, dict) else item for item in data] + if isinstance(data, dict): + return _select_from_item(data, paths) + return data + + +def _select_from_item(item: dict[str, Any], paths: list[str]) -> dict[str, Any]: + groups: dict[str, list[str]] = {} + for path in paths: + top, _, rest = path.partition(".") + groups.setdefault(top, []).append(rest) + result: dict[str, Any] = {} + for top, rests in groups.items(): + if top not in item: + continue + value = item[top] + if isinstance(value, (dict, list)) and all(rests): + result[top] = select_fields(value, rests) + else: + result[top] = value + return result diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index 5933a09..487c6bd 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -3,7 +3,7 @@ import datetime from unittest.mock import MagicMock -from taiga.mcp_server.serialize import strip_avatar_fields, to_jsonable +from taiga.mcp_server.serialize import select_fields, strip_avatar_fields, to_jsonable from taiga.models.base import InstanceResource @@ -115,3 +115,70 @@ def test_strip_avatar_fields_passes_through_non_dict_non_list_values(): assert strip_avatar_fields("hello") == "hello" assert strip_avatar_fields(42) == 42 assert strip_avatar_fields(None) is None + + +def test_select_fields_keeps_only_requested_top_level_keys(): + data = {"id": 1, "subject": "hello", "status": 2} + + result = select_fields(data, ["id", "subject"]) + + assert result == {"id": 1, "subject": "hello"} + + +def test_select_fields_projects_dotted_path_into_nested_dict(): + data = { + "ref": 42, + "status_extra_info": {"id": 3, "name": "In progress", "color": "#000000"}, + } + + result = select_fields(data, ["ref", "status_extra_info.name"]) + + assert result == {"ref": 42, "status_extra_info": {"name": "In progress"}} + + +def test_select_fields_ignores_paths_not_present_in_item(): + data = {"id": 1} + + result = select_fields(data, ["id", "missing", "missing.nested"]) + + assert result == {"id": 1} + + +def test_select_fields_applies_per_item_when_data_is_a_list(): + data = [{"id": 1, "subject": "a"}, {"id": 2, "subject": "b"}] + + result = select_fields(data, ["id"]) + + assert result == [{"id": 1}, {"id": 2}] + + +def test_select_fields_bare_key_wins_over_dotted_path_for_same_top_level_key(): + data = {"status": {"id": 3, "name": "In progress"}} + + result = select_fields(data, ["status", "status.name"]) + + assert result == {"status": {"id": 3, "name": "In progress"}} + + +def test_select_fields_supports_nested_path_deeper_than_two_levels(): + data = {"a": {"b": {"c": 1, "d": 2}}} + + result = select_fields(data, ["a.b.c"]) + + assert result == {"a": {"b": {"c": 1}}} + + +def test_select_fields_projects_into_each_item_of_a_nested_list(): + data = {"id": 1, "epics": [{"ref": 10, "subject": "Epic A"}, {"ref": 11, "subject": "Epic B"}]} + + result = select_fields(data, ["id", "epics.ref"]) + + assert result == {"id": 1, "epics": [{"ref": 10}, {"ref": 11}]} + + +def test_select_fields_nested_list_items_missing_the_path_are_dropped_down_to_empty_dict(): + data = {"epics": [{"ref": 10}, {"subject": "no ref here"}]} + + result = select_fields(data, ["epics.ref"]) + + assert result == {"epics": [{"ref": 10}, {}]} From 6c680bcaaf3c09ea34c4aa2752178c73f015b56b Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:25:41 +0200 Subject: [PATCH 12/58] feat(mcp): add collapse_extra_info helper Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/serialize.py | 27 +++++++++++++ tests/test_mcp_server_serialize.py | 62 +++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index a968ecf..e749d35 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -66,3 +66,30 @@ def _select_from_item(item: dict[str, Any], paths: list[str]) -> dict[str, Any]: else: result[top] = value return result + + +_LABEL_KEYS = ("name", "full_name_display") +_EXTRA_INFO_EXTRA_KEEP = {"status_extra_info": ("is_closed",)} + + +def collapse_extra_info(data: Any) -> Any: + """Shrink every `*_extra_info` block to its id plus whichever descriptive label + field is present. + + Keeps `name` or `full_name_display` (the only two label fields used across + owner_extra_info, assigned_to_extra_info, project_extra_info and status_extra_info in + this codebase), and additionally `is_closed` for status_extra_info specifically, so a + compact caller can still tell whether an item is closed without expanding the block. + """ + if isinstance(data, dict): + result: dict[str, Any] = {} + for key, value in data.items(): + if key.endswith("_extra_info") and isinstance(value, dict): + keep = ("id", *_LABEL_KEYS, *_EXTRA_INFO_EXTRA_KEEP.get(key, ())) + result[key] = {k: value[k] for k in keep if k in value} + else: + result[key] = collapse_extra_info(value) + return result + if isinstance(data, list): + return [collapse_extra_info(item) for item in data] + return data diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index 487c6bd..b95487a 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -3,7 +3,7 @@ import datetime from unittest.mock import MagicMock -from taiga.mcp_server.serialize import select_fields, strip_avatar_fields, to_jsonable +from taiga.mcp_server.serialize import collapse_extra_info, select_fields, strip_avatar_fields, to_jsonable from taiga.models.base import InstanceResource @@ -182,3 +182,63 @@ def test_select_fields_nested_list_items_missing_the_path_are_dropped_down_to_em result = select_fields(data, ["epics.ref"]) assert result == {"epics": [{"ref": 10}, {}]} + + +def test_collapse_extra_info_keeps_id_and_name_for_project_like_blocks(): + data = { + "id": 1, + "project_extra_info": {"id": 7, "name": "Demo", "slug": "demo", "logo_small_url": "https://x/y.png"}, + } + + result = collapse_extra_info(data) + + assert result == {"id": 1, "project_extra_info": {"id": 7, "name": "Demo"}} + + +def test_collapse_extra_info_keeps_id_and_full_name_display_for_user_like_blocks(): + data = { + "owner_extra_info": { + "id": 3, + "full_name_display": "Alice", + "username": "alice", + "photo": "https://x/a.png", + } + } + + result = collapse_extra_info(data) + + assert result == {"owner_extra_info": {"id": 3, "full_name_display": "Alice"}} + + +def test_collapse_extra_info_keeps_is_closed_for_status_extra_info_only(): + data = { + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True, "color": "#00ff00"}, + "project_extra_info": {"id": 7, "name": "Demo", "is_closed": True}, + } + + result = collapse_extra_info(data) + + assert result == { + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "project_extra_info": {"id": 7, "name": "Demo"}, + } + + +def test_collapse_extra_info_recurses_into_list_of_dicts(): + data = [ + {"owner_extra_info": {"id": 1, "full_name_display": "Alice", "photo": "x"}}, + {"owner_extra_info": {"id": 2, "full_name_display": "Bob", "photo": "y"}}, + ] + + result = collapse_extra_info(data) + + assert result == [ + {"owner_extra_info": {"id": 1, "full_name_display": "Alice"}}, + {"owner_extra_info": {"id": 2, "full_name_display": "Bob"}}, + ] + + +def test_collapse_extra_info_leaves_non_extra_info_keys_untouched(): + data = {"id": 1, "subject": "hello", "user_stories": [{"id": 10}]} + + assert collapse_extra_info(data) == data From 32901bfe53cdeb781f9d1d6783c8919c845f7069 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:30:05 +0200 Subject: [PATCH 13/58] feat(mcp): add MINIMAL_FIELDS and apply_payload composition Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/serialize.py | 93 +++++++++++++++++++ tests/test_mcp_server_serialize.py | 140 ++++++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index e749d35..27bdb08 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -93,3 +93,96 @@ def collapse_extra_info(data: Any) -> Any: if isinstance(data, list): return [collapse_extra_info(item) for item in data] return data + + +MINIMAL_FIELDS: dict[str, list[str]] = { + "milestone": ["id", "name", "slug", "project", "estimated_start", "estimated_finish", "closed"], + "userstory": [ + "id", + "ref", + "subject", + "version", + "milestone", + "milestone_name", + "status", + "status_extra_info.name", + "status_extra_info.is_closed", + "is_closed", + "finish_date", + "is_blocked", + "assigned_to_extra_info.full_name_display", + "epics.ref", + ], + "issue": [ + "id", + "ref", + "subject", + "version", + "milestone", + "milestone_name", + "status", + "status_extra_info.name", + "status_extra_info.is_closed", + "is_closed", + "finish_date", + "is_blocked", + "assigned_to_extra_info.full_name_display", + "epics.ref", + ], + "epic": ["id", "ref", "subject", "status_extra_info.name", "project"], +} + + +def _merge_paths(projected: dict[str, Any], source: dict[str, Any], paths: list[str]) -> dict[str, Any]: + """Copy each named top-level key's full, unprojected value from `source` into `projected`.""" + result = dict(projected) + for key in paths: + if key in source: + result[key] = source[key] + return result + + +def apply_payload( + data: Any, + entity: str, + *, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> Any: + """Apply the payload/fields/strip_media/expand projection to a jsonable dict/list. + + `payload="full"` with `fields`/`expand` both unset and `strip_media` not explicitly + `True` is always identity - this is what guarantees the byte-identical-by-default + compatibility contract. `MINIMAL_FIELDS` only has measured entries for "milestone", + "userstory", "issue" and "epic"; for any other `entity`, `payload="minimal"` falls + back to the same projection as `payload="compact"`. + """ + if payload == "full" and fields is None and expand is None and strip_media in (None, False): + return data + + if isinstance(data, list): + return [ + apply_payload(item, entity, payload=payload, fields=fields, strip_media=strip_media, expand=expand) + for item in data + ] + + 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) + elif payload == "compact": + out = collapse_extra_info(data) + else: + out = data + + strip = strip_media if strip_media is not None else payload in ("compact", "minimal") + if strip: + out = strip_avatar_fields(out) + + if expand: + out = _merge_paths(out, data, expand) + + return out diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index b95487a..08fc2d2 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -3,7 +3,13 @@ import datetime from unittest.mock import MagicMock -from taiga.mcp_server.serialize import collapse_extra_info, select_fields, strip_avatar_fields, to_jsonable +from taiga.mcp_server.serialize import ( + apply_payload, + collapse_extra_info, + select_fields, + strip_avatar_fields, + to_jsonable, +) from taiga.models.base import InstanceResource @@ -242,3 +248,135 @@ def test_collapse_extra_info_leaves_non_extra_info_keys_untouched(): data = {"id": 1, "subject": "hello", "user_stories": [{"id": 10}]} assert collapse_extra_info(data) == data + + +# --- apply_payload --------------------------------------------------------------------- + + +def test_apply_payload_returns_data_unchanged_by_default(): + data = {"id": 1, "owner_extra_info": {"photo": "x", "full_name_display": "Alice"}} + + assert apply_payload(data, "userstory") == data + assert apply_payload(data, "userstory", payload="full") == data + + +def test_apply_payload_full_with_only_strip_media_strips_without_collapsing(): + # Regression test for the source spec's own pseudocode bug: payload="full" combined + # with an explicit strip_media=True must strip media only, not fall through to + # compact's collapse_extra_info behavior. + data = { + "id": 1, + "subject": "hello", + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x", "username": "alice"}, + } + + result = apply_payload(data, "userstory", payload="full", strip_media=True) + + assert result == { + "id": 1, + "subject": "hello", + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "username": "alice"}, + } + + +def test_apply_payload_compact_collapses_extra_info_and_strips_media_by_default(): + data = { + "id": 1, + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}, + } + + result = apply_payload(data, "userstory", payload="compact") + + assert result == {"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice"}} + + +def test_apply_payload_compact_with_strip_media_false_keeps_top_level_media(): + data = {"id": 1, "photo": "https://x/a.png"} + + result = apply_payload(data, "membership", payload="compact", strip_media=False) + + assert result == {"id": 1, "photo": "https://x/a.png"} + + +def test_apply_payload_minimal_uses_the_entity_measured_field_set(): + data = { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "epics": [{"ref": 10, "subject": "Epic A"}], + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "y"}, + } + + result = apply_payload(data, "userstory", payload="minimal") + + assert result == { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"full_name_display": "Bob"}, + "epics": [{"ref": 10}], + } + + +def test_apply_payload_minimal_falls_back_to_compact_for_entity_without_a_measured_set(): + data = {"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}} + + result = apply_payload(data, "task", payload="minimal") + + assert result == apply_payload(data, "task", payload="compact") + assert result == {"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice"}} + + +def test_apply_payload_fields_overrides_payload(): + data = {"id": 1, "subject": "hello", "status": 2} + + result = apply_payload(data, "userstory", payload="minimal", fields=["id", "subject"]) + + assert result == {"id": 1, "subject": "hello"} + + +def test_apply_payload_expand_adds_full_block_back_on_top_of_minimal(): + data = { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "epics": [], + } + + result = apply_payload(data, "userstory", payload="minimal", expand=["assigned_to_extra_info"]) + + assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob", "photo": "x"} + + +def test_apply_payload_applies_per_item_when_data_is_a_list(): + data = [{"id": 1, "subject": "a", "status": 2}, {"id": 2, "subject": "b", "status": 3}] + + result = apply_payload(data, "userstory", fields=["id", "subject"]) + + assert result == [{"id": 1, "subject": "a"}, {"id": 2, "subject": "b"}] From b66dd007b1d0a69a2ba79f501a1ae49e50717d0e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:35:39 +0200 Subject: [PATCH 14/58] feat(mcp): add payload/fields/strip_media/expand to project and membership tools --- taiga/mcp_server/server.py | 64 ++++++++++++++++++++++++++++++++------ tests/test_mcp_server.py | 46 +++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 2f213b9..c1d309f 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -9,7 +9,7 @@ from mcp.server.mcpserver import MCPServer from .auth import get_client -from .serialize import to_jsonable +from .serialize import apply_payload, to_jsonable mcp = MCPServer( name="taiga", @@ -99,25 +99,56 @@ def whoami() -> dict[str, Any]: @mcp.tool() -def list_projects(member: int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_projects( + member: int | None = None, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> list[dict[str, Any]]: """List projects visible to the authenticated user, optionally filtered by member id. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for projects yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. """ query = dict(filters or {}) if member is not None: query["member"] = member - return to_jsonable(get_client().projects.list(**_paginated(query))) + result = to_jsonable(get_client().projects.list(**_paginated(query))) + return apply_payload(result, "project", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_project(project: str | int) -> dict[str, Any]: - """Get full project detail by numeric id or slug, including statuses/priorities/severities/points.""" +def get_project( + project: str | int, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: + """Get full project detail by numeric id or slug, including statuses/priorities/severities/points. + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for projects yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. + """ client = get_client() if isinstance(project, int) or str(project).isdigit(): - return to_jsonable(client.projects.get(int(project))) - return to_jsonable(client.projects.get_by_slug(str(project))) + result = to_jsonable(client.projects.get(int(project))) + else: + result = to_jsonable(client.projects.get_by_slug(str(project))) + return apply_payload(result, "project", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() @@ -136,16 +167,31 @@ def search(project: str | int, text: str = "") -> dict[str, Any]: @mcp.tool() -def list_memberships(project: str | int, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_memberships( + project: str | int, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> list[dict[str, Any]]: """List a project's memberships (username, full_name, user_email, role_name, etc.) - the pool of users assignable as owner/assigned_to/watcher on that project's items. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further. + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for memberships yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. """ proj = _resolve_project(project) query = _paginated(dict(filters or {})) - return to_jsonable(proj.list_memberships(**query)) + result = to_jsonable(proj.list_memberships(**query)) + return apply_payload(result, "membership", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 80b83ad..29c0a94 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1159,3 +1159,49 @@ def test_update_wiki_page(mock_get_client): mock_client.wikipages.get.assert_has_calls([call(1), call(1)]) mock_resource.patch.assert_called_once_with(["content"], content="Updated") assert result == {"id": 1, "content": "Updated"} + + +@patch("taiga.mcp_server.server.get_client") +def test_get_project_payload_compact_strips_logo(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get.return_value = { + "id": 1, + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "logo_small_url": "https://x/a.png"}, + } + mock_get_client.return_value = mock_client + + result = server.get_project(1, payload="compact") + + assert result == {"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice"}} + + +@patch("taiga.mcp_server.server.get_client") +def test_list_memberships_fields(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_client.projects.get_by_slug.return_value = mock_project + mock_project.list_memberships.return_value = [ + {"user": 10, "full_name_display": "Alice", "photo": "https://example.com/a.png"} + ] + mock_get_client.return_value = mock_client + + result = server.list_memberships(1, fields=["user", "full_name_display"]) + + assert result == [{"user": 10, "full_name_display": "Alice"}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_memberships_payload_minimal_falls_back_to_compact(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_client.projects.get_by_slug.return_value = mock_project + mock_project.list_memberships.return_value = [ + {"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}} + ] + mock_get_client.return_value = mock_client + + result = server.list_memberships(1, payload="minimal") + + assert result == [{"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice"}}] From dbfedd529b5f7631ffba70edb9603b79b384de56 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:38:55 +0200 Subject: [PATCH 15/58] feat(mcp): add payload/fields/strip_media/expand to user story tools --- taiga/mcp_server/server.py | 58 ++++++++++++++++++++++++++----- tests/test_mcp_server.py | 71 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index c1d309f..d40aea5 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -337,32 +337,74 @@ def set_custom_attribute_value_by_id( @mcp.tool() -def list_user_stories(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_user_stories( + project: str | int | None = None, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> list[dict[str, Any]]: """List user stories, optionally scoped to a project and/or filtered by extra query params. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + + `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" + keeps only id/ref/subject/version/milestone/milestone_name/status/is_closed/ + finish_date/is_blocked plus status_extra_info.name, status_extra_info.is_closed, + assigned_to_extra_info.full_name_display and epics[].ref. `fields` (optionally dotted + paths, e.g. "status_extra_info.name") is an explicit allowlist overriding `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().user_stories.list(**_paginated(query))) + result = to_jsonable(get_client().user_stories.list(**_paginated(query))) + return apply_payload(result, "userstory", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_user_story(project: str | int, ref: int) -> dict[str, Any]: - """Get a user story by its per-project ref number (the number shown in the Taiga UI/URL).""" - return to_jsonable(_get_by_ref("user_story", project, ref)) +def get_user_story( + project: str | int, + ref: int, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: + """Get a user story by its per-project ref number (the number shown in the Taiga UI/URL). + + `payload` ("full" default / "compact" / "minimal") shrinks the response. `fields` + (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. + `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + """ + result = to_jsonable(_get_by_ref("user_story", project, ref)) + return apply_payload(result, "userstory", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_user_story_by_id(id: int) -> dict[str, Any]: # noqa: A002 +def get_user_story_by_id( + id: int, # noqa: A002 + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: """Get a user story by its database id. Secondary lookup: prefer `get_user_story` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. + already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` + ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally + dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) + overrides whether avatar/logo URLs are stripped. `expand` adds named top-level blocks + back at full detail on top of a reduced `payload`. """ - return to_jsonable(get_client().user_stories.get(id)) + result = to_jsonable(get_client().user_stories.get(id)) + return apply_payload(result, "userstory", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 29c0a94..6de0ece 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1205,3 +1205,74 @@ def test_list_memberships_payload_minimal_falls_back_to_compact(mock_get_client) result = server.list_memberships(1, payload="minimal") assert result == [{"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice"}}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_user_stories_payload_minimal_uses_measured_field_set(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.list.return_value = [ + { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "epics": [{"ref": 10, "subject": "Epic A"}], + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "y"}, + } + ] + mock_get_client.return_value = mock_client + + result = server.list_user_stories(payload="minimal") + + assert result == [ + { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"full_name_display": "Bob"}, + "epics": [{"ref": 10}], + } + ] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_user_story_expand_adds_full_block_back(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_userstory_by_ref.return_value = { + "id": 1, + "ref": 45634, + "subject": "hello", + "version": 1, + "milestone": None, + "milestone_name": None, + "status": 2, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "epics": [], + } + mock_get_client.return_value = mock_client + + result = server.get_user_story(1, 45634, payload="minimal", expand=["assigned_to_extra_info"]) + + assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob", "photo": "x"} From 53d0fc2f56c8bbd2923ee527a71772bab0094ca5 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:41:50 +0200 Subject: [PATCH 16/58] feat(mcp): add payload/fields/strip_media/expand to task tools --- taiga/mcp_server/server.py | 58 ++++++++++++++++++++++++++++++++------ tests/test_mcp_server.py | 13 +++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index d40aea5..e338a9a 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -454,35 +454,77 @@ def delete_user_story_by_id(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool() def list_tasks( - project: str | int | None = None, user_story: int | None = None, filters: dict[str, Any] | None = None + project: str | int | None = None, + user_story: int | None = None, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, ) -> list[dict[str, Any]]: """List tasks, optionally scoped to a project and/or a user story. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for tasks yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) if user_story is not None: query["user_story"] = user_story - return to_jsonable(get_client().tasks.list(**_paginated(query))) + result = to_jsonable(get_client().tasks.list(**_paginated(query))) + return apply_payload(result, "task", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_task(project: str | int, ref: int) -> dict[str, Any]: - """Get a task by its per-project ref number (the number shown in the Taiga UI/URL).""" - return to_jsonable(_get_by_ref("task", project, ref)) +def get_task( + project: str | int, + ref: int, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: + """Get a task by its per-project ref number (the number shown in the Taiga UI/URL). + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for tasks yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. + """ + result = to_jsonable(_get_by_ref("task", project, ref)) + return apply_payload(result, "task", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_task_by_id(id: int) -> dict[str, Any]: # noqa: A002 +def get_task_by_id( + id: int, # noqa: A002 + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: """Get a task by its database id. Secondary lookup: prefer `get_task` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. + already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` + ("full" default / "compact" / "minimal" - "minimal" behaves the same as "compact" for + tasks) shrinks the response. `fields` (optionally dotted paths) is an explicit + allowlist overriding `payload`. `strip_media` (True/False) overrides whether + avatar/logo URLs are stripped. `expand` adds named top-level blocks back at full + detail on top of a reduced `payload`. """ - return to_jsonable(get_client().tasks.get(id)) + result = to_jsonable(get_client().tasks.get(id)) + return apply_payload(result, "task", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 6de0ece..2ea6474 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1276,3 +1276,16 @@ def test_get_user_story_expand_adds_full_block_back(mock_get_client): result = server.get_user_story(1, 45634, payload="minimal", expand=["assigned_to_extra_info"]) assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob", "photo": "x"} + + +@patch("taiga.mcp_server.server.get_client") +def test_list_tasks_payload_minimal_falls_back_to_compact(mock_get_client): + mock_client = MagicMock() + mock_client.tasks.list.return_value = [ + {"id": 1, "subject": "hello", "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}} + ] + mock_get_client.return_value = mock_client + + result = server.list_tasks(payload="minimal") + + assert result == [{"id": 1, "subject": "hello", "owner_extra_info": {"id": 9, "full_name_display": "Alice"}}] From 9ad633816dcd199e01405dab674bdddeb89f5f38 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:45:51 +0200 Subject: [PATCH 17/58] feat(mcp): add payload/fields/strip_media/expand to issue tools --- taiga/mcp_server/server.py | 57 ++++++++++++++++++++++++++++++++------ tests/test_mcp_server.py | 41 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index e338a9a..aa3d47a 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -571,32 +571,73 @@ def delete_task_by_id(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool() -def list_issues(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_issues( + project: str | int | None = None, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> list[dict[str, Any]]: """List issues, optionally scoped to a project. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + + `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" + keeps only id/ref/subject/version/milestone/milestone_name/status/is_closed/ + finish_date/is_blocked plus status_extra_info.name, status_extra_info.is_closed, + assigned_to_extra_info.full_name_display and epics[].ref. `fields` (optionally dotted + paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) + overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` + adds named top-level blocks back at full detail on top of a reduced `payload`. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().issues.list(**_paginated(query))) + result = to_jsonable(get_client().issues.list(**_paginated(query))) + return apply_payload(result, "issue", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_issue(project: str | int, ref: int) -> dict[str, Any]: - """Get an issue by its per-project ref number (the number shown in the Taiga UI/URL, e.g. .../issues/45634).""" - return to_jsonable(_get_by_ref("issue", project, ref)) +def get_issue( + project: str | int, + ref: int, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: + """Get an issue by its per-project ref number (the number shown in the Taiga UI/URL, e.g. .../issues/45634). + + `payload` ("full" default / "compact" / "minimal") shrinks the response. `fields` + (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. + `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + """ + result = to_jsonable(_get_by_ref("issue", project, ref)) + return apply_payload(result, "issue", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_issue_by_id(id: int) -> dict[str, Any]: # noqa: A002 +def get_issue_by_id( + id: int, # noqa: A002 + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: """Get an issue by its database id. Secondary lookup: prefer `get_issue` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. + already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` + ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally + dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped. `expand` adds named + top-level blocks back at full detail on top of a reduced `payload`. """ - return to_jsonable(get_client().issues.get(id)) + result = to_jsonable(get_client().issues.get(id)) + return apply_payload(result, "issue", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 2ea6474..62c5ecf 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1289,3 +1289,44 @@ def test_list_tasks_payload_minimal_falls_back_to_compact(mock_get_client): result = server.list_tasks(payload="minimal") assert result == [{"id": 1, "subject": "hello", "owner_extra_info": {"id": 9, "full_name_display": "Alice"}}] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_issue_payload_minimal_uses_measured_field_set(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_issue_by_ref.return_value = { + "id": 1, + "ref": 45634, + "subject": "hello", + "version": 1, + "milestone": None, + "milestone_name": None, + "status": 2, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "epics": [], + } + mock_get_client.return_value = mock_client + + result = server.get_issue(1, 45634, payload="minimal") + + assert result == { + "id": 1, + "ref": 45634, + "subject": "hello", + "version": 1, + "milestone": None, + "milestone_name": None, + "status": 2, + "status_extra_info": {"name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"full_name_display": "Bob"}, + "epics": [], + } From 50afc7ffb1ed7ef58a661d13d4029be83372c3d9 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:50:09 +0200 Subject: [PATCH 18/58] feat(mcp): add payload/fields/strip_media/expand to epic tools Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 55 ++++++++++++++++++++++++++++++++------ tests/test_mcp_server.py | 22 +++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index aa3d47a..3c253ad 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -694,32 +694,71 @@ def delete_issue_by_id(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool() -def list_epics(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_epics( + project: str | int | None = None, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> list[dict[str, Any]]: """List epics, optionally scoped to a project. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + + `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" + keeps only id/ref/subject/project plus status_extra_info.name. `fields` (optionally + dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. + `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().epics.list(**_paginated(query))) + result = to_jsonable(get_client().epics.list(**_paginated(query))) + return apply_payload(result, "epic", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_epic(project: str | int, ref: int) -> dict[str, Any]: - """Get an epic by its per-project ref number (the number shown in the Taiga UI/URL).""" - return to_jsonable(_get_by_ref("epic", project, ref)) +def get_epic( + project: str | int, + ref: int, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: + """Get an epic by its per-project ref number (the number shown in the Taiga UI/URL). + + `payload` ("full" default / "compact" / "minimal") shrinks the response. `fields` + (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. + `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + """ + result = to_jsonable(_get_by_ref("epic", project, ref)) + return apply_payload(result, "epic", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_epic_by_id(id: int) -> dict[str, Any]: # noqa: A002 +def get_epic_by_id( + id: int, # noqa: A002 + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: """Get an epic by its database id. Secondary lookup: prefer `get_epic` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. + already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` + ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally + dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped. `expand` adds named + top-level blocks back at full detail on top of a reduced `payload`. """ - return to_jsonable(get_client().epics.get(id)) + result = to_jsonable(get_client().epics.get(id)) + return apply_payload(result, "epic", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 62c5ecf..33933b4 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1330,3 +1330,25 @@ def test_get_issue_payload_minimal_uses_measured_field_set(mock_get_client): "assigned_to_extra_info": {"full_name_display": "Bob"}, "epics": [], } + + +@patch("taiga.mcp_server.server.get_client") +def test_list_epics_payload_minimal_uses_measured_field_set(mock_get_client): + mock_client = MagicMock() + mock_client.epics.list.return_value = [ + { + "id": 1, + "ref": 7, + "subject": "hello", + "project": 5, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}, + } + ] + mock_get_client.return_value = mock_client + + result = server.list_epics(payload="minimal") + + assert result == [ + {"id": 1, "ref": 7, "subject": "hello", "status_extra_info": {"name": "Done"}, "project": 5} + ] From 179abcff48ca21d2a28f00942c75054fd72be0d2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:53:40 +0200 Subject: [PATCH 19/58] feat(mcp): add payload/fields/strip_media/expand to milestone tools --- taiga/mcp_server/server.py | 33 ++++++++++++++++++++++--- tests/test_mcp_server.py | 49 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 3c253ad..270187e 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -838,6 +838,10 @@ def list_milestones( project: str | int | None = None, filters: dict[str, Any] | None = None, include_user_stories: bool = True, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, ) -> list[dict[str, Any]]: """List milestones (sprints), optionally scoped to a project. @@ -845,23 +849,46 @@ def list_milestones( `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. Each milestone embeds its full `user_stories`; pass `include_user_stories=False` to strip that (potentially large) field from every returned milestone. + + `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" + keeps only id/name/slug/project/estimated_start/estimated_finish/closed. `fields` + (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. + `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) result = to_jsonable(get_client().milestones.list(**_paginated(query))) - return result if include_user_stories else _strip_user_stories(result) + if not include_user_stories: + result = _strip_user_stories(result) + return apply_payload(result, "milestone", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_milestone(id: int, include_user_stories: bool = True) -> dict[str, Any]: # noqa: A002 +def get_milestone( + id: int, # noqa: A002 + include_user_stories: bool = True, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: """Get a milestone by id. The milestone embeds its full `user_stories`; pass `include_user_stories=False` to strip that (potentially large) field from the returned milestone. + + `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" + keeps only id/name/slug/project/estimated_start/estimated_finish/closed. `fields` + (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` + (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. + `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. """ result = to_jsonable(get_client().milestones.get(id)) - return result if include_user_stories else _strip_user_stories(result) + if not include_user_stories: + result = _strip_user_stories(result) + return apply_payload(result, "milestone", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 33933b4..b9d9434 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1349,6 +1349,51 @@ def test_list_epics_payload_minimal_uses_measured_field_set(mock_get_client): result = server.list_epics(payload="minimal") - assert result == [ - {"id": 1, "ref": 7, "subject": "hello", "status_extra_info": {"name": "Done"}, "project": 5} + assert result == [{"id": 1, "ref": 7, "subject": "hello", "status_extra_info": {"name": "Done"}, "project": 5}] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_milestones_include_user_stories_false_and_payload_compact_combine(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.list.return_value = [ + { + "id": 1, + "name": "Sprint 1", + "user_stories": [{"id": 10}], + "project_extra_info": {"id": 5, "name": "Demo", "logo_small_url": "https://x/logo.png"}, + } ] + mock_get_client.return_value = mock_client + + result = server.list_milestones(include_user_stories=False, payload="compact") + + assert result == [{"id": 1, "name": "Sprint 1", "project_extra_info": {"id": 5, "name": "Demo"}}] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_milestone_payload_minimal_uses_measured_field_set(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.get.return_value = { + "id": 1, + "name": "Sprint 1", + "slug": "sprint-1", + "project": 5, + "estimated_start": "2026-09-01", + "estimated_finish": "2026-09-14", + "closed": False, + "user_stories": [{"id": 10}], + "project_extra_info": {"id": 5, "name": "Demo", "logo_small_url": "https://x/logo.png"}, + } + mock_get_client.return_value = mock_client + + result = server.get_milestone(1, payload="minimal") + + assert result == { + "id": 1, + "name": "Sprint 1", + "slug": "sprint-1", + "project": 5, + "estimated_start": "2026-09-01", + "estimated_finish": "2026-09-14", + "closed": False, + } From 1f3084a968122fb97e757fc8b9591c1fca8f456f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 14:57:16 +0200 Subject: [PATCH 20/58] feat(mcp): add payload/fields/strip_media/expand to wiki page tools Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 40 +++++++++++++++++++++++++++++++++----- tests/test_mcp_server.py | 26 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 270187e..043f492 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -915,22 +915,52 @@ def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 @mcp.tool() -def list_wiki_pages(project: str | int | None = None, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: +def list_wiki_pages( + project: str | int | None = None, + filters: dict[str, Any] | None = None, + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> list[dict[str, Any]]: """List wiki pages, optionally scoped to a project. Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for wiki pages yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) - return to_jsonable(get_client().wikipages.list(**_paginated(query))) + result = to_jsonable(get_client().wikipages.list(**_paginated(query))) + return apply_payload(result, "wikipage", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() -def get_wiki_page(id: int) -> dict[str, Any]: # noqa: A002 - """Get a wiki page by id.""" - return to_jsonable(get_client().wikipages.get(id)) +def get_wiki_page( + id: int, # noqa: A002 + payload: str = "full", + fields: list[str] | None = None, + strip_media: bool | None = None, + expand: list[str] | None = None, +) -> dict[str, Any]: + """Get a wiki page by id. + + `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned + for wiki pages yet and currently behaves the same as "compact") shrinks the response. + `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. + `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless + of `payload`. `expand` adds named top-level blocks back at full detail on top of a + reduced `payload`. + """ + result = to_jsonable(get_client().wikipages.get(id)) + return apply_payload(result, "wikipage", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b9d9434..592a3a0 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1397,3 +1397,29 @@ def test_get_milestone_payload_minimal_uses_measured_field_set(mock_get_client): "estimated_finish": "2026-09-14", "closed": False, } + + +@patch("taiga.mcp_server.server.get_client") +def test_list_wiki_pages_default_unchanged(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.list.return_value = [{"id": 1, "slug": "home", "content": "hello"}] + mock_get_client.return_value = mock_client + + result = server.list_wiki_pages() + + assert result == [{"id": 1, "slug": "home", "content": "hello"}] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_wiki_page_strip_media_true(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.get.return_value = { + "id": 1, + "slug": "home", + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}, + } + mock_get_client.return_value = mock_client + + result = server.get_wiki_page(1, strip_media=True) + + assert result == {"id": 1, "slug": "home", "owner_extra_info": {"id": 9, "full_name_display": "Alice"}} From f0796038d925278215dd7479e05a1448945b9716 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:00:20 +0200 Subject: [PATCH 21/58] feat(mcp): add _resolve_assigned_users helper --- taiga/mcp_server/server.py | 30 +++++++++++++++ tests/test_mcp_server.py | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 043f492..d2f6d6d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -333,6 +333,36 @@ def set_custom_attribute_value_by_id( return to_jsonable(resource.set_attribute(attribute_id, value, version=version)) +def _resolve_assigned_users( + data: dict[str, Any] | list[dict[str, Any]], +) -> dict[str, Any] | list[dict[str, Any]]: + """Attach `assigned_users_extra_info` (id + full_name_display) to each item's bare + `assigned_users` id list, resolving names via each distinct project's memberships. + + Memberships share the same `full_name_display` field every other Taiga user block in + this codebase uses (`owner_extra_info`, `assigned_to_extra_info`, ...). Fetches at most + one membership page (up to 100 members) per distinct project id actually referenced. + """ + items = data if isinstance(data, list) else [data] + if not any(item.get("assigned_users") for item in items): + return data + client = get_client() + membership_maps: dict[int, dict[int, str | None]] = {} + for item in items: + assigned_users = item.get("assigned_users") + if not 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_display") for m in memberships} + 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 + ] + return data + + # --- User stories ----------------------------------------------------------------- diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 592a3a0..ad799d1 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1423,3 +1423,81 @@ def test_get_wiki_page_strip_media_true(mock_get_client): result = server.get_wiki_page(1, strip_media=True) assert result == {"id": 1, "slug": "home", "owner_extra_info": {"id": 9, "full_name_display": "Alice"}} + + +# --- _resolve_assigned_users ----------------------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_assigned_users_attaches_names_from_project_memberships(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.list_memberships.return_value = [ + {"user": 10, "full_name_display": "Alice"}, + {"user": 11, "full_name_display": "Bob"}, + ] + mock_get_client.return_value = mock_client + + data = [{"id": 1, "project": 5, "assigned_users": [10, 11]}] + + result = server._resolve_assigned_users(data) + + assert result[0]["assigned_users_extra_info"] == [ + {"id": 10, "full_name_display": "Alice"}, + {"id": 11, "full_name_display": "Bob"}, + ] + mock_client.projects.get.assert_called_once_with(5) + mock_project.list_memberships.assert_called_once_with(page=1, page_size=100) + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_assigned_users_fetches_memberships_once_per_distinct_project(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_get_client.return_value = mock_client + + data = [ + {"id": 1, "project": 5, "assigned_users": [10]}, + {"id": 2, "project": 5, "assigned_users": [10]}, + ] + + server._resolve_assigned_users(data) + + mock_client.projects.get.assert_called_once_with(5) + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_assigned_users_unmatched_id_gets_none_name(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_get_client.return_value = mock_client + + data = {"id": 1, "project": 5, "assigned_users": [10, 99]} + + result = server._resolve_assigned_users(data) + + assert result["assigned_users_extra_info"] == [ + {"id": 10, "full_name_display": "Alice"}, + {"id": 99, "full_name_display": None}, + ] + + +def test_resolve_assigned_users_no_op_when_assigned_users_absent(): + data = {"id": 1, "project": 5} + + result = server._resolve_assigned_users(data) + + assert "assigned_users_extra_info" not in result + + +def test_resolve_assigned_users_no_op_when_assigned_users_empty(): + data = {"id": 1, "project": 5, "assigned_users": []} + + result = server._resolve_assigned_users(data) + + assert "assigned_users_extra_info" not in result From 335f84fad706775add7ae774a7d28503b214263e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:04:22 +0200 Subject: [PATCH 22/58] feat(mcp): add resolve_assigned_users to user story tools --- taiga/mcp_server/server.py | 21 +++++++++++++++++++-- tests/test_mcp_server.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index d2f6d6d..106ab71 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -374,6 +374,7 @@ def list_user_stories( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + resolve_assigned_users: bool = False, ) -> list[dict[str, Any]]: """List user stories, optionally scoped to a project and/or filtered by extra query params. @@ -387,12 +388,17 @@ def list_user_stories( paths, e.g. "status_extra_info.name") is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + reduced `payload`. `assigned_users` is a bare id list with no names in the payload; + pass `resolve_assigned_users=True` to attach `assigned_users_extra_info` (id + + full_name_display) - costs one extra memberships call per distinct project in the + result, so it's opt-in. """ query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) result = to_jsonable(get_client().user_stories.list(**_paginated(query))) + if resolve_assigned_users: + result = _resolve_assigned_users(result) return apply_payload(result, "userstory", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -404,6 +410,7 @@ def get_user_story( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + resolve_assigned_users: bool = False, ) -> dict[str, Any]: """Get a user story by its per-project ref number (the number shown in the Taiga UI/URL). @@ -411,8 +418,13 @@ def get_user_story( (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `assigned_users` is a bare id list with no names in the payload; pass + `resolve_assigned_users=True` to attach `assigned_users_extra_info` (id + + full_name_display) - costs one extra memberships call, so it's opt-in. """ result = to_jsonable(_get_by_ref("user_story", project, ref)) + if resolve_assigned_users: + result = _resolve_assigned_users(result) return apply_payload(result, "userstory", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -423,6 +435,7 @@ def get_user_story_by_id( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + resolve_assigned_users: bool = False, ) -> dict[str, Any]: """Get a user story by its database id. @@ -431,9 +444,13 @@ def get_user_story_by_id( ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped. `expand` adds named top-level blocks - back at full detail on top of a reduced `payload`. + back at full detail on top of a reduced `payload`. Pass `resolve_assigned_users=True` + to attach `assigned_users_extra_info` (id + full_name_display) - costs one extra + memberships call. """ result = to_jsonable(get_client().user_stories.get(id)) + if resolve_assigned_users: + result = _resolve_assigned_users(result) return apply_payload(result, "userstory", payload=payload, fields=fields, strip_media=strip_media, expand=expand) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index ad799d1..4d58368 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1501,3 +1501,36 @@ def test_resolve_assigned_users_no_op_when_assigned_users_empty(): result = server._resolve_assigned_users(data) assert "assigned_users_extra_info" not in result + + +@patch("taiga.mcp_server.server.get_client") +def test_list_user_stories_resolve_assigned_users(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.list.return_value = [{"id": 1, "project": 5, "assigned_users": [10]}] + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_get_client.return_value = mock_client + + result = server.list_user_stories(resolve_assigned_users=True) + + assert result == [ + { + "id": 1, + "project": 5, + "assigned_users": [10], + "assigned_users_extra_info": [{"id": 10, "full_name_display": "Alice"}], + } + ] + + +@patch("taiga.mcp_server.server.get_client") +def test_list_user_stories_resolve_assigned_users_defaults_false(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.list.return_value = [{"id": 1, "project": 5, "assigned_users": [10]}] + mock_get_client.return_value = mock_client + + result = server.list_user_stories() + + assert "assigned_users_extra_info" not in result[0] + mock_client.projects.get.assert_not_called() From ab246ec71ac4a18847e326651ec41d518a0528eb Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:10:13 +0200 Subject: [PATCH 23/58] feat(mcp): add strict_filters to list tools Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 61 ++++++++++++++++++++++++++++++++++---- tests/test_mcp_server.py | 36 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 106ab71..7643286 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -92,6 +92,30 @@ def _paginated(query: dict[str, Any]) -> dict[str, Any]: return query +_FILTER_TRAP_KEYS = frozenset( + {"payload", "fields", "strip_media", "expand", "strict_filters", "include_user_stories", "resolve_assigned_users"} +) + + +def _check_strict_filters(filters: dict[str, Any] | None, strict_filters: bool) -> None: + """Raise if `filters` nests one of this server's own parameter names by mistake. + + Taiga's REST backend silently ignores unknown query parameters, so a caller who nests + e.g. `filters={"include_user_stories": False}` instead of passing it top-level gets a + full, unfiltered response with no error - a measured 24x size regression with no + signal either way. Checked against a fixed set of this server's own parameter names, + not Taiga's full (and from this client, unknowable) set of real filterable fields, so + this can never false-positive on a genuine Taiga filter. + """ + if not strict_filters or not filters: + return + trapped = _FILTER_TRAP_KEYS & filters.keys() + if trapped: + raise ValueError( + f"filters contains parameter name(s) meant to be passed top-level, not nested: {sorted(trapped)}" + ) + + @mcp.tool() def whoami() -> dict[str, Any]: """Return the Taiga user currently authenticated.""" @@ -106,6 +130,7 @@ def list_projects( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List projects visible to the authenticated user, optionally filtered by member id. @@ -117,8 +142,10 @@ def list_projects( `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + reduced `payload`. `strict_filters=True` raises if `filters` contains one of this + tool's own parameter names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if member is not None: query["member"] = member @@ -174,6 +201,7 @@ def list_memberships( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List a project's memberships (username, full_name, user_email, role_name, etc.) - the pool of users assignable as owner/assigned_to/watcher on that project's items. @@ -186,8 +214,10 @@ def list_memberships( `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + reduced `payload`. `strict_filters=True` raises if `filters` contains one of this + tool's own parameter names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) proj = _resolve_project(project) query = _paginated(dict(filters or {})) result = to_jsonable(proj.list_memberships(**query)) @@ -375,6 +405,7 @@ def list_user_stories( strip_media: bool | None = None, expand: list[str] | None = None, resolve_assigned_users: bool = False, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List user stories, optionally scoped to a project and/or filtered by extra query params. @@ -391,8 +422,10 @@ def list_user_stories( reduced `payload`. `assigned_users` is a bare id list with no names in the payload; pass `resolve_assigned_users=True` to attach `assigned_users_extra_info` (id + full_name_display) - costs one extra memberships call per distinct project in the - result, so it's opt-in. + result, so it's opt-in. `strict_filters=True` raises if `filters` contains one of this + tool's own parameter names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) @@ -508,6 +541,7 @@ def list_tasks( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List tasks, optionally scoped to a project and/or a user story. @@ -519,8 +553,10 @@ def list_tasks( `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + reduced `payload`. `strict_filters=True` raises if `filters` contains one of this + tool's own parameter names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) @@ -625,6 +661,7 @@ def list_issues( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List issues, optionally scoped to a project. @@ -638,7 +675,10 @@ def list_issues( paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `strict_filters=True` raises if `filters` contains one of this tool's own parameter + names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) @@ -748,6 +788,7 @@ def list_epics( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List epics, optionally scoped to a project. @@ -759,7 +800,10 @@ def list_epics( dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `strict_filters=True` raises if `filters` contains one of this tool's own parameter + names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) @@ -889,6 +933,7 @@ def list_milestones( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List milestones (sprints), optionally scoped to a project. @@ -902,7 +947,10 @@ def list_milestones( (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `strict_filters=True` raises if `filters` contains one of this tool's own parameter + names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) @@ -969,6 +1017,7 @@ def list_wiki_pages( fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, + strict_filters: bool = False, ) -> list[dict[str, Any]]: """List wiki pages, optionally scoped to a project. @@ -980,8 +1029,10 @@ def list_wiki_pages( `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + reduced `payload`. `strict_filters=True` raises if `filters` contains one of this + tool's own parameter names instead of silently ignoring it. """ + _check_strict_filters(filters, strict_filters) query = dict(filters or {}) if project is not None: query["project"] = _resolve_project_id(project) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 4d58368..6d6b0e9 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1534,3 +1534,39 @@ def test_list_user_stories_resolve_assigned_users_defaults_false(mock_get_client assert "assigned_users_extra_info" not in result[0] mock_client.projects.get.assert_not_called() + + +# --- _check_strict_filters -------------------------------------------------------------- + + +def test_check_strict_filters_no_op_when_disabled(): + server._check_strict_filters({"include_user_stories": False}, strict_filters=False) + + +def test_check_strict_filters_no_op_when_filters_none(): + server._check_strict_filters(None, strict_filters=True) + + +def test_check_strict_filters_no_op_when_filters_clean(): + server._check_strict_filters({"closed": False, "project": 1}, strict_filters=True) + + +def test_check_strict_filters_raises_naming_the_trapped_key(): + with pytest.raises(ValueError, match="include_user_stories"): + server._check_strict_filters({"include_user_stories": False}, strict_filters=True) + + +@patch("taiga.mcp_server.server.get_client") +def test_list_milestones_strict_filters_false_ignores_trap_key_silently(mock_get_client): + mock_client = MagicMock() + mock_client.milestones.list.return_value = [{"id": 1}] + mock_get_client.return_value = mock_client + + result = server.list_milestones(filters={"include_user_stories": False}) + + assert result == [{"id": 1}] + + +def test_list_milestones_strict_filters_true_raises(): + with pytest.raises(ValueError, match="include_user_stories"): + server.list_milestones(filters={"include_user_stories": False}, strict_filters=True) From 9dcd6b0f80f39751405686519c74a7c3cac9735a Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:13:46 +0200 Subject: [PATCH 24/58] docs(mcp): document payload/fields/strip_media/expand/resolve_assigned_users/strict_filters --- docs/mcp.rst | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/mcp.rst b/docs/mcp.rst index f26b204..009f6eb 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -258,6 +258,55 @@ Available tools through further pages, and ``order_by`` (e.g. ``-created_date``) to control ordering - for example to fetch the most recent items first. +.. tip:: Every ``list_*``/``get_*`` tool that returns a full resource - projects, + user stories, tasks, issues, epics, milestones, memberships, wiki pages - + accepts four further parameters to shrink an oversized response, all + opt-in (the default reproduces today's full response exactly): + + - ``payload``: ``"full"`` (default, untouched) / ``"compact"`` (drops + avatar/logo URLs and shrinks every ``*_extra_info`` block to its id + plus display name) / ``"minimal"`` (only the fields a sprint-planning + report actually reads - currently defined for user stories, issues, + epics and milestones; for any other resource ``"minimal"`` behaves + the same as ``"compact"`` for now). + - ``fields``: an explicit list of field paths, overriding ``payload`` + entirely, e.g. ``["ref", "subject", "status_extra_info.name"]``. A + dotted path keeps only that nested key; if the value at that point is + itself a list (e.g. a story's ``epics``), the remaining path is + applied to every element, e.g. ``"epics.ref"``. + - ``strip_media``: ``True``/``False``, overriding whether avatar/logo + URLs (``photo``, ``big_photo``, ``gravatar_id``, ``logo_small_url``) + are stripped, regardless of ``payload``. These carry rotating + signed-URL signatures, so leaving them in also defeats prompt caching + between otherwise-identical calls. + - ``expand``: a list of top-level block names to add back at full + detail on top of a reduced ``payload``, e.g. + ``payload="minimal", expand=["assigned_to_extra_info"]``. + + ``list_user_stories``, ``get_user_story`` and ``get_user_story_by_id`` + additionally accept ``resolve_assigned_users=True``: ``assigned_users`` + is a bare list of user ids with no names anywhere in the default + payload, so this resolves them into + ``assigned_users_extra_info: [{"id", "full_name_display"}, ...]`` via + that story's project memberships. Off by default because, unlike the + four parameters above, it adds a request rather than removing one (one + ``list_memberships`` call per distinct project touched). + + Every ``list_*`` tool additionally accepts ``strict_filters=False`` + (default): pass ``True`` to raise immediately if ``filters`` nests one + of this server's own parameter names by mistake (e.g. + ``filters={"include_user_stories": False}``) instead of silently + returning an unfiltered response - Taiga's REST backend ignores + unknown query parameters with no error either way, which measured as + much as a 24x size regression with no signal that anything went wrong. + +.. note:: ``filters`` is forwarded as-is to Taiga's REST endpoint, so + server-side filtering - e.g. ``list_milestones(filters={"closed": + False, "estimated_start__lte": "2026-09-20"})`` - already works + today with no MCP-side change, for any lookup Taiga's own API + filter backend supports. Which lookups that includes is a property + of the Taiga server you're talking to, not of this client. + **************** Security notes **************** From 476b218618c848a7eebff52f442469262f0901f9 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:26:21 +0200 Subject: [PATCH 25/58] fix(mcp): type payload as Literal, include assigned_users_extra_info in userstory minimal set Addresses final-review findings: `payload` was typed `str` instead of the spec-mandated `Literal["full","compact","minimal"]` on all 19 tool signatures, allowing an invalid value to silently pass through as `payload="full"` with no error. `MINIMAL_FIELDS["userstory"]` didn't include `assigned_users_extra_info`, so `resolve_assigned_users=True` combined with `payload="minimal"` silently discarded the enrichment the caller just paid an extra API call for. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/serialize.py | 1 + taiga/mcp_server/server.py | 38 ++++++++++++------------- tests/test_mcp_server.py | 14 ++++++++++ tests/test_mcp_server_serialize.py | 45 ++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index 27bdb08..ad2418f 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -112,6 +112,7 @@ def collapse_extra_info(data: Any) -> Any: "is_blocked", "assigned_to_extra_info.full_name_display", "epics.ref", + "assigned_users_extra_info", ], "issue": [ "id", diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 7643286..944d640 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -126,7 +126,7 @@ def whoami() -> dict[str, Any]: def list_projects( member: int | None = None, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -156,7 +156,7 @@ def list_projects( @mcp.tool() def get_project( project: str | int, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -197,7 +197,7 @@ def search(project: str | int, text: str = "") -> dict[str, Any]: def list_memberships( project: str | int, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -400,7 +400,7 @@ def _resolve_assigned_users( def list_user_stories( project: str | int | None = None, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -439,7 +439,7 @@ def list_user_stories( def get_user_story( project: str | int, ref: int, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -464,7 +464,7 @@ def get_user_story( @mcp.tool() def get_user_story_by_id( id: int, # noqa: A002 - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -537,7 +537,7 @@ def list_tasks( project: str | int | None = None, user_story: int | None = None, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -570,7 +570,7 @@ def list_tasks( def get_task( project: str | int, ref: int, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -591,7 +591,7 @@ def get_task( @mcp.tool() def get_task_by_id( id: int, # noqa: A002 - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -657,7 +657,7 @@ def delete_task_by_id(id: int) -> dict[str, str]: # noqa: A002 def list_issues( project: str | int | None = None, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -690,7 +690,7 @@ def list_issues( def get_issue( project: str | int, ref: int, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -709,7 +709,7 @@ def get_issue( @mcp.tool() def get_issue_by_id( id: int, # noqa: A002 - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -784,7 +784,7 @@ def delete_issue_by_id(id: int) -> dict[str, str]: # noqa: A002 def list_epics( project: str | int | None = None, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -815,7 +815,7 @@ def list_epics( def get_epic( project: str | int, ref: int, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -834,7 +834,7 @@ def get_epic( @mcp.tool() def get_epic_by_id( id: int, # noqa: A002 - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -929,7 +929,7 @@ def list_milestones( project: str | int | None = None, filters: dict[str, Any] | None = None, include_user_stories: bool = True, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -964,7 +964,7 @@ def list_milestones( def get_milestone( id: int, # noqa: A002 include_user_stories: bool = True, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -1013,7 +1013,7 @@ def delete_milestone(id: int) -> dict[str, str]: # noqa: A002 def list_wiki_pages( project: str | int | None = None, filters: dict[str, Any] | None = None, - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, @@ -1043,7 +1043,7 @@ def list_wiki_pages( @mcp.tool() def get_wiki_page( id: int, # noqa: A002 - payload: str = "full", + payload: Literal["full", "compact", "minimal"] = "full", fields: list[str] | None = None, strip_media: bool | None = None, expand: list[str] | None = None, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 6d6b0e9..440a6b7 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1536,6 +1536,20 @@ def test_list_user_stories_resolve_assigned_users_defaults_false(mock_get_client mock_client.projects.get.assert_not_called() +@patch("taiga.mcp_server.server.get_client") +def test_list_user_stories_resolve_assigned_users_survives_payload_minimal(mock_get_client): + mock_client = MagicMock() + mock_client.user_stories.list.return_value = [{"id": 1, "project": 5, "assigned_users": [10]}] + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_get_client.return_value = mock_client + + result = server.list_user_stories(resolve_assigned_users=True, payload="minimal") + + assert result[0]["assigned_users_extra_info"] == [{"id": 10, "full_name_display": "Alice"}] + + # --- _check_strict_filters -------------------------------------------------------------- diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index 08fc2d2..aef6aea 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -335,6 +335,51 @@ def test_apply_payload_minimal_uses_the_entity_measured_field_set(): } +def test_apply_payload_minimal_keeps_assigned_users_extra_info_for_userstory(): + data = { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"id": 2, "name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "epics": [{"ref": 10, "subject": "Epic A"}], + "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "y"}, + "assigned_users_extra_info": [ + {"id": 10, "full_name_display": "Alice"}, + {"id": 11, "full_name_display": "Bob"}, + ], + } + + result = apply_payload(data, "userstory", payload="minimal") + + assert result == { + "id": 1, + "ref": 42, + "subject": "hello", + "version": 3, + "milestone": 7, + "milestone_name": "Sprint 1", + "status": 2, + "status_extra_info": {"name": "Done", "is_closed": True}, + "is_closed": True, + "finish_date": None, + "is_blocked": False, + "assigned_to_extra_info": {"full_name_display": "Bob"}, + "epics": [{"ref": 10}], + "assigned_users_extra_info": [ + {"id": 10, "full_name_display": "Alice"}, + {"id": 11, "full_name_display": "Bob"}, + ], + } + + def test_apply_payload_minimal_falls_back_to_compact_for_entity_without_a_measured_set(): data = {"id": 1, "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "x"}} From 24183db5f15abc9cf4a6841a10ece08a9c888337 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:33:29 +0200 Subject: [PATCH 26/58] docs(mcp): compress payload-param docstrings, point to docs/mcp.rst Per user decision after final review: verbose per-tool docstrings for payload/fields/strip_media/expand/resolve_assigned_users/strict_filters were repeated near-verbatim across 19 tools, growing the combined tool-schema text 2.2x (7,844 -> 17,335 chars) - paid by every session regardless of whether the new parameters are used. Full semantics remain documented once in docs/mcp.rst; tool docstrings now point there. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 182 ++++++++++++------------------------- 1 file changed, 58 insertions(+), 124 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 944d640..340c0aa 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -137,13 +137,9 @@ def list_projects( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for projects yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. `strict_filters=True` raises if `filters` contains one of this - tool's own parameter names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -163,12 +159,8 @@ def get_project( ) -> dict[str, Any]: """Get full project detail by numeric id or slug, including statuses/priorities/severities/points. - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for projects yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ client = get_client() if isinstance(project, int) or str(project).isdigit(): @@ -209,13 +201,9 @@ def list_memberships( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further. - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for memberships yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist that overrides `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. `strict_filters=True` raises if `filters` contains one of this - tool's own parameter names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) proj = _resolve_project(project) @@ -412,18 +400,11 @@ def list_user_stories( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. - `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" - keeps only id/ref/subject/version/milestone/milestone_name/status/is_closed/ - finish_date/is_blocked plus status_extra_info.name, status_extra_info.is_closed, - assigned_to_extra_info.full_name_display and epics[].ref. `fields` (optionally dotted - paths, e.g. "status_extra_info.name") is an explicit allowlist overriding `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. `assigned_users` is a bare id list with no names in the payload; - pass `resolve_assigned_users=True` to attach `assigned_users_extra_info` (id + - full_name_display) - costs one extra memberships call per distinct project in the - result, so it's opt-in. `strict_filters=True` raises if `filters` contains one of this - tool's own parameter names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `resolve_assigned_users=True` attaches + `assigned_users_extra_info` (costs an extra API call per project) - see docs/mcp.rst. + `strict_filters=True` raises instead of silently ignoring a `filters` key that collides + with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -447,13 +428,9 @@ def get_user_story( ) -> dict[str, Any]: """Get a user story by its per-project ref number (the number shown in the Taiga UI/URL). - `payload` ("full" default / "compact" / "minimal") shrinks the response. `fields` - (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. - `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. - `assigned_users` is a bare id list with no names in the payload; pass - `resolve_assigned_users=True` to attach `assigned_users_extra_info` (id + - full_name_display) - costs one extra memberships call, so it's opt-in. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `resolve_assigned_users=True` attaches + `assigned_users_extra_info` (costs an extra API call per project) - see docs/mcp.rst. """ result = to_jsonable(_get_by_ref("user_story", project, ref)) if resolve_assigned_users: @@ -473,13 +450,11 @@ def get_user_story_by_id( """Get a user story by its database id. Secondary lookup: prefer `get_user_story` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` - ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally - dotted paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) - overrides whether avatar/logo URLs are stripped. `expand` adds named top-level blocks - back at full detail on top of a reduced `payload`. Pass `resolve_assigned_users=True` - to attach `assigned_users_extra_info` (id + full_name_display) - costs one extra - memberships call. + already hold the raw database id, not the ref shown in the Taiga UI/URL. + + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `resolve_assigned_users=True` attaches + `assigned_users_extra_info` (costs an extra API call per project) - see docs/mcp.rst. """ result = to_jsonable(get_client().user_stories.get(id)) if resolve_assigned_users: @@ -548,13 +523,9 @@ def list_tasks( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for tasks yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. `strict_filters=True` raises if `filters` contains one of this - tool's own parameter names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -577,12 +548,8 @@ def get_task( ) -> dict[str, Any]: """Get a task by its per-project ref number (the number shown in the Taiga UI/URL). - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for tasks yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(_get_by_ref("task", project, ref)) return apply_payload(result, "task", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -599,12 +566,10 @@ def get_task_by_id( """Get a task by its database id. Secondary lookup: prefer `get_task` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` - ("full" default / "compact" / "minimal" - "minimal" behaves the same as "compact" for - tasks) shrinks the response. `fields` (optionally dotted paths) is an explicit - allowlist overriding `payload`. `strip_media` (True/False) overrides whether - avatar/logo URLs are stripped. `expand` adds named top-level blocks back at full - detail on top of a reduced `payload`. + already hold the raw database id, not the ref shown in the Taiga UI/URL. + + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(get_client().tasks.get(id)) return apply_payload(result, "task", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -668,15 +633,9 @@ def list_issues( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. - `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" - keeps only id/ref/subject/version/milestone/milestone_name/status/is_closed/ - finish_date/is_blocked plus status_extra_info.name, status_extra_info.is_closed, - assigned_to_extra_info.full_name_display and epics[].ref. `fields` (optionally dotted - paths) is an explicit allowlist overriding `payload`. `strip_media` (True/False) - overrides whether avatar/logo URLs are stripped, regardless of `payload`. `expand` - adds named top-level blocks back at full detail on top of a reduced `payload`. - `strict_filters=True` raises if `filters` contains one of this tool's own parameter - names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -697,10 +656,8 @@ def get_issue( ) -> dict[str, Any]: """Get an issue by its per-project ref number (the number shown in the Taiga UI/URL, e.g. .../issues/45634). - `payload` ("full" default / "compact" / "minimal") shrinks the response. `fields` - (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. - `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(_get_by_ref("issue", project, ref)) return apply_payload(result, "issue", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -717,11 +674,10 @@ def get_issue_by_id( """Get an issue by its database id. Secondary lookup: prefer `get_issue` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` - ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally - dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped. `expand` adds named - top-level blocks back at full detail on top of a reduced `payload`. + already hold the raw database id, not the ref shown in the Taiga UI/URL. + + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(get_client().issues.get(id)) return apply_payload(result, "issue", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -795,13 +751,9 @@ def list_epics( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. - `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" - keeps only id/ref/subject/project plus status_extra_info.name. `fields` (optionally - dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. - `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. - `strict_filters=True` raises if `filters` contains one of this tool's own parameter - names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -822,10 +774,8 @@ def get_epic( ) -> dict[str, Any]: """Get an epic by its per-project ref number (the number shown in the Taiga UI/URL). - `payload` ("full" default / "compact" / "minimal") shrinks the response. `fields` - (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. - `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(_get_by_ref("epic", project, ref)) return apply_payload(result, "epic", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -842,11 +792,10 @@ def get_epic_by_id( """Get an epic by its database id. Secondary lookup: prefer `get_epic` with a project + ref. Use this only when you - already hold the raw database id, not the ref shown in the Taiga UI/URL. `payload` - ("full" default / "compact" / "minimal") shrinks the response. `fields` (optionally - dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped. `expand` adds named - top-level blocks back at full detail on top of a reduced `payload`. + already hold the raw database id, not the ref shown in the Taiga UI/URL. + + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(get_client().epics.get(id)) return apply_payload(result, "epic", payload=payload, fields=fields, strip_media=strip_media, expand=expand) @@ -942,13 +891,9 @@ def list_milestones( Each milestone embeds its full `user_stories`; pass `include_user_stories=False` to strip that (potentially large) field from every returned milestone. - `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" - keeps only id/name/slug/project/estimated_start/estimated_finish/closed. `fields` - (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. - `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. - `strict_filters=True` raises if `filters` contains one of this tool's own parameter - names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -974,11 +919,8 @@ def get_milestone( The milestone embeds its full `user_stories`; pass `include_user_stories=False` to strip that (potentially large) field from the returned milestone. - `payload` ("full" default / "compact" / "minimal") shrinks the response - "minimal" - keeps only id/name/slug/project/estimated_start/estimated_finish/closed. `fields` - (optionally dotted paths) is an explicit allowlist overriding `payload`. `strip_media` - (True/False) overrides whether avatar/logo URLs are stripped, regardless of `payload`. - `expand` adds named top-level blocks back at full detail on top of a reduced `payload`. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(get_client().milestones.get(id)) if not include_user_stories: @@ -1024,13 +966,9 @@ def list_wiki_pages( Paginated: defaults to page 1 of up to 100 results. Pass `filters` with `page`/ `page_size` to page further, or `order_by` (e.g. '-created_date') to control order. - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for wiki pages yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. `strict_filters=True` raises if `filters` contains one of this - tool's own parameter names instead of silently ignoring it. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. `strict_filters=True` raises instead of silently + ignoring a `filters` key that collides with this tool's own parameter names. """ _check_strict_filters(filters, strict_filters) query = dict(filters or {}) @@ -1050,12 +988,8 @@ def get_wiki_page( ) -> dict[str, Any]: """Get a wiki page by id. - `payload` ("full" default / "compact" / "minimal" - "minimal" isn't specially tuned - for wiki pages yet and currently behaves the same as "compact") shrinks the response. - `fields` (optionally dotted paths) is an explicit allowlist overriding `payload`. - `strip_media` (True/False) overrides whether avatar/logo URLs are stripped, regardless - of `payload`. `expand` adds named top-level blocks back at full detail on top of a - reduced `payload`. + `payload`/`fields`/`strip_media`/`expand` optionally shrink the response - see + docs/mcp.rst for full semantics. """ result = to_jsonable(get_client().wikipages.get(id)) return apply_payload(result, "wikipage", payload=payload, fields=fields, strip_media=strip_media, expand=expand) From 528e1b0574dce7eaf03dc752f9b1a4b06356763f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:36:49 +0200 Subject: [PATCH 27/58] docs(mcp): restore minimal-field-set enumeration lost in docstring compression The prior docstring-compression commit removed the only place that listed exactly which fields payload="minimal" keeps for user stories, issues, epics and milestones, and docs/mcp.rst never had this detail either - leaving it unrecoverable without reading MINIMAL_FIELDS source. Adds the four field lists to the existing payload/fields/strip_media/expand tip block, sourced from serialize.py's MINIMAL_FIELDS. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index 009f6eb..ee9bc8c 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -268,7 +268,26 @@ Available tools plus display name) / ``"minimal"`` (only the fields a sprint-planning report actually reads - currently defined for user stories, issues, epics and milestones; for any other resource ``"minimal"`` behaves - the same as ``"compact"`` for now). + the same as ``"compact"`` for now). The exact field set kept by + ``payload="minimal"`` per entity (``MINIMAL_FIELDS`` in + ``taiga/mcp_server/serialize.py``): + + - ``milestone`` (``list_milestones``/``get_milestone``): ``id``, + ``name``, ``slug``, ``project``, ``estimated_start``, + ``estimated_finish``, ``closed``. + - ``userstory`` (``list_user_stories``/``get_user_story``/ + ``get_user_story_by_id``): ``id``, ``ref``, ``subject``, + ``version``, ``milestone``, ``milestone_name``, ``status``, + ``status_extra_info.name``, ``status_extra_info.is_closed``, + ``is_closed``, ``finish_date``, ``is_blocked``, + ``assigned_to_extra_info.full_name_display``, ``epics.ref``, + ``assigned_users_extra_info``. + - ``issue`` (``list_issues``/``get_issue``/``get_issue_by_id``): + same as ``userstory`` minus ``assigned_users_extra_info`` + (issues have no ``assigned_users``). + - ``epic`` (``list_epics``/``get_epic``/``get_epic_by_id``): + ``id``, ``ref``, ``subject``, ``status_extra_info.name``, + ``project``. - ``fields``: an explicit list of field paths, overriding ``payload`` entirely, e.g. ``["ref", "subject", "status_extra_info.name"]``. A dotted path keeps only that nested key; if the value at that point is From 99356e7b741517e40c687455e9700d4fab6ab2eb Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:40:15 +0200 Subject: [PATCH 28/58] docs(mcp): add docstring to _select_from_item doc-sync pass flagged this new private helper as missing a docstring - its dotted-path grouping and bare-wins-over-dotted collision logic isn't obvious from the name/type hints alone. --- taiga/mcp_server/serialize.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index ad2418f..bd545a9 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -52,6 +52,9 @@ def select_fields(data: Any, paths: list[str]) -> Any: def _select_from_item(item: dict[str, Any], paths: list[str]) -> dict[str, Any]: + """Apply select_fields' path rules to a single dict: group by top-level key, + then recurse for a purely-dotted group or keep the bare value if any path + targeting that key was bare (bare beats dotted on a collision).""" groups: dict[str, list[str]] = {} for path in paths: top, _, rest = path.partition(".") From e2b7dd7e3bda367026233adbe8331ed180ccf77c Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Wed, 16 Sep 2026 15:48:14 +0200 Subject: [PATCH 29/58] docs(changes): add towncrier fragment for payload-reduction feature --- changes/+mcp-payload-reduction.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/+mcp-payload-reduction.feature diff --git a/changes/+mcp-payload-reduction.feature b/changes/+mcp-payload-reduction.feature new file mode 100644 index 0000000..3de3382 --- /dev/null +++ b/changes/+mcp-payload-reduction.feature @@ -0,0 +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. From 87a889fa16ad26ac9cfd2a0e6174252ae8cfa8e6 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 11:36:02 +0200 Subject: [PATCH 30/58] 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 568e42c..8bc9008 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.0b3" +current_version = "2.0.0b4" parse = """(?x) (?P[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index 6351f31..d50e127 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "2.0.0b3" +__version__ = "2.0.0b4" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] From d239ebeac86c785201aeb7db5031e540d20e5534 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:29:32 +0200 Subject: [PATCH 31/58] feat(mcp): add _represent helper for return_representation Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 19 +++++++++++++++++++ tests/test_mcp_server.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 340c0aa..ca8ad9e 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -67,6 +67,25 @@ def _get_by_ref(entity_type: str, project: str | int, ref: int) -> Any: return getattr(proj, _REF_METHOD[entity_type])(ref) +def _represent( + resource: Any, + written: dict[str, Any], + return_representation: str, +) -> dict[str, Any]: + """Project a written resource down per return_representation - "minimal"/"none" only. + + Callers handle "full" themselves: that shape differs between create_* (no re-fetch) + and update_* (re-fetch required), and duplicating that branching here would obscure, + not simplify, either. + """ + base: dict[str, Any] = {"id": resource.id, "version": getattr(resource, "version", None)} + if hasattr(resource, "ref"): + base["ref"] = resource.ref + if return_representation == "minimal": + return {**base, **written} + return {"ok": True, **base} + + DEFAULT_PAGE_SIZE = 100 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 440a6b7..3db2927 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1584,3 +1584,38 @@ def test_list_milestones_strict_filters_false_ignores_trap_key_silently(mock_get def test_list_milestones_strict_filters_true_raises(): with pytest.raises(ValueError, match="include_user_stories"): server.list_milestones(filters={"include_user_stories": False}, strict_filters=True) + + +# --- _represent ------------------------------------------------------------------------ + + +def test_represent_minimal_includes_id_ref_version_and_written_fields(): + resource = MagicMock(id=1, version=5, ref=42) + + result = server._represent(resource, {"subject": "Updated"}, "minimal") + + assert result == {"id": 1, "version": 5, "ref": 42, "subject": "Updated"} + + +def test_represent_minimal_omits_ref_when_resource_has_none(): + resource = MagicMock(spec=["id", "version"], id=1, version=5) + + result = server._represent(resource, {"content": "Updated"}, "minimal") + + assert result == {"id": 1, "version": 5, "content": "Updated"} + + +def test_represent_none_includes_only_ok_id_ref_version(): + resource = MagicMock(id=1, version=5, ref=42) + + result = server._represent(resource, {"subject": "Updated"}, "none") + + assert result == {"ok": True, "id": 1, "version": 5, "ref": 42} + + +def test_represent_none_omits_ref_when_resource_has_none(): + resource = MagicMock(spec=["id", "version"], id=1, version=5) + + result = server._represent(resource, {}, "none") + + assert result == {"ok": True, "id": 1, "version": 5} From 130cef71fc39b0a0f79bb8ae701f7304cb34874d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:32:34 +0200 Subject: [PATCH 32/58] feat(mcp): add return_representation to user story write tools Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 60 ++++++++++++++++++++++++++++++-------- tests/test_mcp_server.py | 42 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index ca8ad9e..2d05952 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -482,30 +482,66 @@ def get_user_story_by_id( @mcp.tool() -def create_user_story(project: str | int, subject: str, fields: dict[str, Any] | None = None) -> dict[str, Any]: - """Create a user story. `fields` may set status, points, milestone, description, tags, etc.""" +def create_user_story( + project: str | int, + subject: str, + fields: dict[str, Any] | None = None, + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Create a user story. `fields` may set status, points, milestone, description, tags, etc. + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + created resource comes back - "minimal" returns just id/ref/version plus whatever was + in `fields`; "none" returns only an acknowledgement. See docs/mcp.rst for the exact shape. + """ pid = _resolve_project_id(project) - return to_jsonable(get_client().user_stories.create(pid, subject, **(fields or {}))) + resource = get_client().user_stories.create(pid, subject, **(fields or {})) + if return_representation == "full": + return to_jsonable(resource) + return _represent(resource, fields or {}, return_representation) @mcp.tool() -def update_user_story(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: - """Update a user story identified by its per-project ref number. `fields` is a dict of the attributes to change.""" - # InstanceResource.patch() only refreshes `version` on the local object, not the other - # fields the server actually applied, so the result must be re-fetched, not serialized - # from the patched object itself. +def update_user_story( + project: str | int, + ref: int, + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update a user story identified by its per-project ref number. `fields` is a dict of the attributes to change. + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + updated resource comes back. "full" re-fetches the complete resource, exactly as before - + InstanceResource.patch() only refreshes `version` on the local object, not the other + fields the server actually applied, so "full" must re-fetch, not serialize from the + patched object itself. "minimal"/"none" skip that re-fetch entirely - they only need + id/ref/version (already on the patched-in-place resource) and, for "minimal", the + caller's own `fields` (already known). See docs/mcp.rst. + """ resource = _get_by_ref("user_story", project, ref) resource.patch(list(fields.keys()), **fields) - return to_jsonable(get_client().user_stories.get(resource.id)) + if return_representation == "full": + return to_jsonable(get_client().user_stories.get(resource.id)) + return _represent(resource, fields, return_representation) @mcp.tool() -def update_user_story_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update a user story by its database id. Secondary lookup - prefer `update_user_story` with a project + ref.""" +def update_user_story_by_id( + id: int, # noqa: A002 + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update a user story by its database id. Secondary lookup - prefer `update_user_story` with a project + ref. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ client = get_client() resource = client.user_stories.get(id) resource.patch(list(fields.keys()), **fields) - return to_jsonable(client.user_stories.get(id)) + if return_representation == "full": + return to_jsonable(client.user_stories.get(id)) + return _represent(resource, fields, return_representation) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 3db2927..0632f8d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1619,3 +1619,45 @@ def test_represent_none_omits_ref_when_resource_has_none(): result = server._represent(resource, {}, "none") assert result == {"ok": True, "id": 1, "version": 5} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_user_story_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock(id=1, version=1, ref=99) + mock_client.user_stories.create.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.create_user_story(1, "New story", fields={"points": {"1": 2}}, return_representation="minimal") + + assert result == {"id": 1, "version": 1, "ref": 99, "points": {"1": 2}} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_user_story_minimal_skips_refetch(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=1, version=3, ref=45634) + mock_project.get_userstory_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_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_client.user_stories.get.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_update_user_story_by_id_none(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock(id=1, version=4, ref=45634) + mock_client.user_stories.get.return_value = mock_resource + mock_get_client.return_value = mock_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_client.user_stories.get.assert_called_once_with(1) From f8f2f7c050246ee9aa363a074847c4318054167d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:36:50 +0200 Subject: [PATCH 33/58] feat(mcp): add return_representation to task write tools Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 54 +++++++++++++++++++++++++++++++------- tests/test_mcp_server.py | 12 +++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 2d05952..33ca83c 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -631,28 +631,62 @@ def get_task_by_id( @mcp.tool() -def create_task(project: str | int, subject: str, status: int, fields: dict[str, Any] | None = None) -> dict[str, Any]: - """Create a task. `status` is the numeric task-status id (see get_project). `fields` may set user_story, etc.""" +def create_task( + project: str | int, + subject: str, + status: int, + fields: dict[str, Any] | None = None, + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Create a task. `status` is the numeric task-status id (see get_project). `fields` may set user_story, etc. + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + created resource comes back - see docs/mcp.rst. + """ pid = _resolve_project_id(project) - return to_jsonable(get_client().tasks.create(pid, subject, status, **(fields or {}))) + resource = get_client().tasks.create(pid, subject, status, **(fields or {})) + if return_representation == "full": + return to_jsonable(resource) + return _represent(resource, fields or {}, return_representation) @mcp.tool() -def update_task(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: - """Update a task identified by its per-project ref number. `fields` is a dict of the attributes to change.""" - # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. +def update_task( + project: str | int, + ref: int, + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update a task identified by its per-project ref number. `fields` is a dict of the attributes to change. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". resource = _get_by_ref("task", project, ref) resource.patch(list(fields.keys()), **fields) - return to_jsonable(get_client().tasks.get(resource.id)) + if return_representation == "full": + return to_jsonable(get_client().tasks.get(resource.id)) + return _represent(resource, fields, return_representation) @mcp.tool() -def update_task_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update a task by its database id. Secondary lookup - prefer `update_task` with a project + ref.""" +def update_task_by_id( + id: int, # noqa: A002 + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update a task by its database id. Secondary lookup - prefer `update_task` with a project + ref. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ client = get_client() resource = client.tasks.get(id) resource.patch(list(fields.keys()), **fields) - return to_jsonable(client.tasks.get(id)) + if return_representation == "full": + return to_jsonable(client.tasks.get(id)) + return _represent(resource, fields, return_representation) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0632f8d..595f8c8 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1661,3 +1661,15 @@ def test_update_user_story_by_id_none(mock_get_client): assert result == {"ok": True, "id": 1, "version": 4, "ref": 45634} mock_resource.patch.assert_called_once_with(["subject"], subject="Updated") mock_client.user_stories.get.assert_called_once_with(1) + + +@patch("taiga.mcp_server.server.get_client") +def test_create_task_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock(id=2, version=1, ref=50) + mock_client.tasks.create.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.create_task(1, "New task", 3, fields={"user_story": 10}, return_representation="minimal") + + assert result == {"id": 2, "version": 1, "ref": 50, "user_story": 10} From 1fa58766272dc777e1a904363ff80e6642bdb52d Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:40:37 +0200 Subject: [PATCH 34/58] feat(mcp): add return_representation to issue write tools --- taiga/mcp_server/server.py | 49 +++++++++++++++++++++++++++++--------- tests/test_mcp_server.py | 15 ++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 33ca83c..2b7b1a9 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -781,30 +781,57 @@ def create_issue( issue_type: int, severity: int, fields: dict[str, Any] | None = None, + return_representation: Literal["full", "minimal", "none"] = "full", ) -> dict[str, Any]: - """Create an issue. `priority`/`status`/`issue_type`/`severity` are numeric ids (see get_project).""" + """Create an issue. `priority`/`status`/`issue_type`/`severity` are numeric ids (see get_project). + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + created resource comes back - see docs/mcp.rst. + """ pid = _resolve_project_id(project) - return to_jsonable( - get_client().issues.create(pid, subject, priority, status, issue_type, severity, **(fields or {})) - ) + resource = get_client().issues.create(pid, subject, priority, status, issue_type, severity, **(fields or {})) + if return_representation == "full": + return to_jsonable(resource) + return _represent(resource, fields or {}, return_representation) @mcp.tool() -def update_issue(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: - """Update an issue identified by its per-project ref number. `fields` is a dict of the attributes to change.""" - # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. +def update_issue( + project: str | int, + ref: int, + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update an issue identified by its per-project ref number. `fields` is a dict of the attributes to change. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". resource = _get_by_ref("issue", project, ref) resource.patch(list(fields.keys()), **fields) - return to_jsonable(get_client().issues.get(resource.id)) + if return_representation == "full": + return to_jsonable(get_client().issues.get(resource.id)) + return _represent(resource, fields, return_representation) @mcp.tool() -def update_issue_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update an issue by its database id. Secondary lookup - prefer `update_issue` with a project + ref.""" +def update_issue_by_id( + id: int, # noqa: A002 + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update an issue by its database id. Secondary lookup - prefer `update_issue` with a project + ref. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ client = get_client() resource = client.issues.get(id) resource.patch(list(fields.keys()), **fields) - return to_jsonable(client.issues.get(id)) + if return_representation == "full": + return to_jsonable(client.issues.get(id)) + return _represent(resource, fields, return_representation) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 595f8c8..b21edfc 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1673,3 +1673,18 @@ def test_create_task_return_representation_minimal(mock_get_client): result = server.create_task(1, "New task", 3, fields={"user_story": 10}, return_representation="minimal") assert result == {"id": 2, "version": 1, "ref": 50, "user_story": 10} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_issue_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=3, version=2, ref=60) + mock_project.get_issue_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_issue(1, 60, {"status": 5}, return_representation="minimal") + + assert result == {"id": 3, "version": 2, "ref": 60, "status": 5} + mock_client.issues.get.assert_not_called() From 841de31282abe453c2697c4142932b10315ea6d0 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:43:40 +0200 Subject: [PATCH 35/58] feat(mcp): add return_representation to epic write tools Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 53 +++++++++++++++++++++++++++++++------- tests/test_mcp_server.py | 12 +++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 2b7b1a9..d0721dd 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -918,28 +918,61 @@ def get_epic_by_id( @mcp.tool() -def create_epic(project: str | int, subject: str, fields: dict[str, Any] | None = None) -> dict[str, Any]: - """Create an epic.""" +def create_epic( + project: str | int, + subject: str, + fields: dict[str, Any] | None = None, + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Create an epic. + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + created resource comes back - see docs/mcp.rst. + """ pid = _resolve_project_id(project) - return to_jsonable(get_client().epics.create(pid, subject, **(fields or {}))) + resource = get_client().epics.create(pid, subject, **(fields or {})) + if return_representation == "full": + return to_jsonable(resource) + return _represent(resource, fields or {}, return_representation) @mcp.tool() -def update_epic(project: str | int, ref: int, fields: dict[str, Any]) -> dict[str, Any]: - """Update an epic identified by its per-project ref number. `fields` is a dict of the attributes to change.""" - # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. +def update_epic( + project: str | int, + ref: int, + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update an epic identified by its per-project ref number. `fields` is a dict of the attributes to change. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". resource = _get_by_ref("epic", project, ref) resource.patch(list(fields.keys()), **fields) - return to_jsonable(get_client().epics.get(resource.id)) + if return_representation == "full": + return to_jsonable(get_client().epics.get(resource.id)) + return _represent(resource, fields, return_representation) @mcp.tool() -def update_epic_by_id(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update an epic by its database id. Secondary lookup - prefer `update_epic` with a project + ref.""" +def update_epic_by_id( + id: int, # noqa: A002 + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update an epic by its database id. Secondary lookup - prefer `update_epic` with a project + ref. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + """ client = get_client() resource = client.epics.get(id) resource.patch(list(fields.keys()), **fields) - return to_jsonable(client.epics.get(id)) + if return_representation == "full": + return to_jsonable(client.epics.get(id)) + return _represent(resource, fields, return_representation) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index b21edfc..9c60872 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1688,3 +1688,15 @@ def test_update_issue_return_representation_minimal(mock_get_client): assert result == {"id": 3, "version": 2, "ref": 60, "status": 5} mock_client.issues.get.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_create_epic_return_representation_none(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock(id=4, version=1, ref=70) + mock_client.epics.create.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.create_epic(1, "New epic", return_representation="none") + + assert result == {"ok": True, "id": 4, "version": 1, "ref": 70} From 2b2a3983151ea912d99eebedf204e6e20d01864e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:48:19 +0200 Subject: [PATCH 36/58] feat(mcp): add return_representation to create_milestone Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 12 ++++++++++-- tests/test_mcp_server.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index d0721dd..ca75cfe 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -1084,10 +1084,18 @@ def create_milestone( estimated_start: str, estimated_finish: str, fields: dict[str, Any] | None = None, + return_representation: Literal["full", "minimal", "none"] = "full", ) -> dict[str, Any]: - """Create a milestone. Dates are ISO strings ('YYYY-MM-DD').""" + """Create a milestone. Dates are ISO strings ('YYYY-MM-DD'). + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + created resource comes back - see docs/mcp.rst. + """ pid = _resolve_project_id(project) - return to_jsonable(get_client().milestones.create(pid, name, estimated_start, estimated_finish, **(fields or {}))) + resource = get_client().milestones.create(pid, name, estimated_start, estimated_finish, **(fields or {})) + if return_representation == "full": + return to_jsonable(resource) + return _represent(resource, fields or {}, return_representation) @mcp.tool() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 9c60872..4d55f21 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1700,3 +1700,17 @@ def test_create_epic_return_representation_none(mock_get_client): result = server.create_epic(1, "New epic", return_representation="none") assert result == {"ok": True, "id": 4, "version": 1, "ref": 70} + + +@patch("taiga.mcp_server.server.get_client") +def test_create_milestone_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock(spec=["id", "version"]) + mock_resource.id = 5 + mock_resource.version = 1 + mock_client.milestones.create.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.create_milestone(1, "Sprint 1", "2026-09-01", "2026-09-14", return_representation="minimal") + + assert result == {"id": 5, "version": 1} From a43f7c15ea802679c00f174be2c2aa139b094ce7 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:53:09 +0200 Subject: [PATCH 37/58] feat(mcp): add return_representation to wiki page write tools --- taiga/mcp_server/server.py | 37 ++++++++++++++++++++++++++++++------- tests/test_mcp_server.py | 14 ++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index ca75cfe..7e5a01d 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -1154,18 +1154,41 @@ def get_wiki_page( @mcp.tool() def create_wiki_page( - project: str | int, slug: str, content: str, fields: dict[str, Any] | None = None + project: str | int, + slug: str, + content: str, + fields: dict[str, Any] | None = None, + return_representation: Literal["full", "minimal", "none"] = "full", ) -> dict[str, Any]: - """Create a wiki page.""" + """Create a wiki page. + + `return_representation` ("full" default / "minimal" / "none") controls how much of the + created resource comes back - see docs/mcp.rst. Wiki pages have no `ref` number, so + "minimal"/"none" here never include a `ref` key. + """ pid = _resolve_project_id(project) - return to_jsonable(get_client().wikipages.create(pid, slug, content, **(fields or {}))) + resource = get_client().wikipages.create(pid, slug, content, **(fields or {})) + if return_representation == "full": + return to_jsonable(resource) + return _represent(resource, fields or {}, return_representation) @mcp.tool() -def update_wiki_page(id: int, fields: dict[str, Any]) -> dict[str, Any]: # noqa: A002 - """Update a wiki page. `fields` is a dict of the attributes to change.""" - # See update_user_story: patch() doesn't refresh the local object, so re-fetch it. +def update_wiki_page( + id: int, # noqa: A002 + fields: dict[str, Any], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> dict[str, Any]: + """Update a wiki page. `fields` is a dict of the attributes to change. + + `return_representation` ("full" default / "minimal" / "none") - see `update_user_story` + for the full explanation; "minimal"/"none" skip the re-fetch this tool otherwise performs. + Wiki pages have no `ref` number, so "minimal"/"none" here never include a `ref` key. + """ + # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". client = get_client() resource = client.wikipages.get(id) resource.patch(list(fields.keys()), **fields) - return to_jsonable(client.wikipages.get(id)) + if return_representation == "full": + return to_jsonable(client.wikipages.get(id)) + return _represent(resource, fields, return_representation) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 4d55f21..9782673 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1161,6 +1161,20 @@ def test_update_wiki_page(mock_get_client): assert result == {"id": 1, "content": "Updated"} +@patch("taiga.mcp_server.server.get_client") +def test_update_wiki_page_minimal_has_no_ref_key(mock_get_client): + mock_client = MagicMock() + mock_resource = MagicMock(spec=["id", "version", "patch"], id=6, version=2) + mock_client.wikipages.get.return_value = mock_resource + mock_get_client.return_value = mock_client + + result = server.update_wiki_page(6, {"content": "Updated"}, return_representation="minimal") + + assert result == {"id": 6, "version": 2, "content": "Updated"} + assert "ref" not in result + mock_resource.patch.assert_called_once_with(["content"], content="Updated") + + @patch("taiga.mcp_server.server.get_client") def test_get_project_payload_compact_strips_logo(mock_get_client): mock_client = MagicMock() From a3cd159326a5522328e09fa95f2b8e08a616701b Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 12:57:35 +0200 Subject: [PATCH 38/58] feat(mcp): add update_work_items batch-write tool Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 41 +++++++++++++++++++++ tests/test_mcp_server.py | 75 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 7e5a01d..d8fdeea 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -1010,6 +1010,47 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An return to_jsonable(epic.add_related_user_story(user_story_id)) +@mcp.tool() +def update_work_items( + project: str | int, + updates: list[dict[str, Any]], + return_representation: Literal["full", "minimal", "none"] = "full", +) -> list[dict[str, Any]]: + """Update a batch of user stories/tasks/issues/epics in one call. + + 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. + + 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, + in the same order, so `updates` and the result can always be zipped. A successful item's row + follows `return_representation` exactly like a single-item `update_*` call. A failed item's + row is `{"status": "error", "entity_type": ..., "ref": ..., "error": ""}`. + + 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. + """ + results: list[dict[str, Any]] = [] + for item in updates: + entity_type = item["entity_type"] + ref = item["ref"] + fields = item["fields"] + try: + resource = _get_by_ref(entity_type, project, ref) + resource.patch(list(fields.keys()), **fields) + 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. + results.append({"status": "error", "entity_type": entity_type, "ref": ref, "error": str(exc)}) + return results + + # --- Milestones (sprints) ----------------------------------------------------------------- diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 9782673..35338f5 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1728,3 +1728,78 @@ def test_create_milestone_return_representation_minimal(mock_get_client): result = server.create_milestone(1, "Sprint 1", "2026-09-01", "2026-09-14", return_representation="minimal") assert result == {"id": 5, "version": 1} + + +# --- update_work_items ------------------------------------------------------------------ + + +def test_update_work_items_empty_list(): + assert server.update_work_items(1, []) == [] + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_all_success(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_us = MagicMock(id=1) + mock_task = MagicMock(id=2) + mock_project.get_userstory_by_ref.return_value = mock_us + mock_project.get_task_by_ref.return_value = mock_task + mock_client.user_stories.get.return_value = {"id": 1, "subject": "US updated"} + mock_client.tasks.get.return_value = {"id": 2, "subject": "Task updated"} + mock_get_client.return_value = mock_client + + updates = [ + {"entity_type": "user_story", "ref": 10, "fields": {"subject": "US updated"}}, + {"entity_type": "task", "ref": 20, "fields": {"subject": "Task updated"}}, + ] + + result = server.update_work_items(1, updates) + + mock_us.patch.assert_called_once_with(["subject"], subject="US updated") + mock_task.patch.assert_called_once_with(["subject"], subject="Task updated") + assert result == [ + {"id": 1, "subject": "US updated"}, + {"id": 2, "subject": "Task updated"}, + ] + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_mixed_success_and_failure(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_us = MagicMock(id=1) + mock_project.get_userstory_by_ref.return_value = mock_us + mock_project.get_task_by_ref.side_effect = Exception("boom") + mock_client.user_stories.get.return_value = {"id": 1, "subject": "US updated"} + mock_get_client.return_value = mock_client + + updates = [ + {"entity_type": "user_story", "ref": 10, "fields": {"subject": "US updated"}}, + {"entity_type": "task", "ref": 20, "fields": {"subject": "Task updated"}}, + ] + + result = server.update_work_items(1, updates) + + assert result[0] == {"id": 1, "subject": "US updated"} + assert result[1] == {"status": "error", "entity_type": "task", "ref": 20, "error": "boom"} + mock_us.patch.assert_called_once_with(["subject"], subject="US updated") + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_us = MagicMock(id=1, version=5, ref=10) + mock_project.get_userstory_by_ref.return_value = mock_us + mock_get_client.return_value = mock_client + + updates = [{"entity_type": "user_story", "ref": 10, "fields": {"subject": "Updated"}}] + + result = server.update_work_items(1, updates, return_representation="minimal") + + assert result == [{"id": 1, "version": 5, "ref": 10, "subject": "Updated"}] + mock_client.user_stories.get.assert_not_called() From aeefea9a68ab8aa8156c69889932eb801777e053 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:02:34 +0200 Subject: [PATCH 39/58] fix(mcp): keep update_work_items per-item isolation for malformed items A task review found that entity_type/ref/fields were extracted from each item before the try/except block, so a malformed item (missing a required key) raised KeyError outside the per-item handler - aborting the whole batch and discarding results already built for earlier successful items, contradicting the tool's own "not atomic, one row per item" contract. Moves the extraction inside the try block so a malformed item now produces its own error row instead of crashing the batch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 15 +++++++++++---- tests/test_mcp_server.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index d8fdeea..59928c1 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -1035,10 +1035,10 @@ def update_work_items( """ results: list[dict[str, Any]] = [] for item in updates: - entity_type = item["entity_type"] - ref = item["ref"] - fields = item["fields"] try: + entity_type = item["entity_type"] + ref = item["ref"] + fields = item["fields"] resource = _get_by_ref(entity_type, project, ref) resource.patch(list(fields.keys()), **fields) if return_representation == "full": @@ -1047,7 +1047,14 @@ def update_work_items( else: results.append(_represent(resource, fields, return_representation)) except Exception as exc: # A single bad item must not abort the rest of the batch. - results.append({"status": "error", "entity_type": entity_type, "ref": ref, "error": str(exc)}) + results.append( + { + "status": "error", + "entity_type": item.get("entity_type"), + "ref": item.get("ref"), + "error": str(exc), + } + ) return results diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 35338f5..efa92fa 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1803,3 +1803,29 @@ def test_update_work_items_return_representation_minimal(mock_get_client): assert result == [{"id": 1, "version": 5, "ref": 10, "subject": "Updated"}] mock_client.user_stories.get.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_malformed_item_does_not_abort_batch(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_us = MagicMock(id=1) + mock_project.get_userstory_by_ref.return_value = mock_us + mock_client.user_stories.get.return_value = {"id": 1, "subject": "US updated"} + mock_get_client.return_value = mock_client + + updates = [ + {"entity_type": "user_story", "ref": 10, "fields": {"subject": "US updated"}}, + {"entity_type": "task", "ref": 20}, # missing "fields" - malformed + ] + + result = server.update_work_items(1, updates) + + assert len(result) == 2 + assert result[0] == {"id": 1, "subject": "US updated"} + assert result[1]["status"] == "error" + assert result[1]["entity_type"] == "task" + assert result[1]["ref"] == 20 + assert isinstance(result[1]["error"], str) and result[1]["error"] + mock_us.patch.assert_called_once_with(["subject"], subject="US updated") From 483f9dc160ddceed8121c1ef32ad22b9cecfae20 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:04:56 +0200 Subject: [PATCH 40/58] docs(mcp): document return_representation and update_work_items --- docs/mcp.rst | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/mcp.rst b/docs/mcp.rst index ee9bc8c..66da78d 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -219,6 +219,10 @@ Available tools Link a user story to an epic, identifying both by their per-project ref numbers (primary) or by database id (secondary, see below). +``update_work_items`` + Update a batch of user stories/tasks/issues/epics in one call - see the + tip below for the exact shape and its non-atomic semantics. + .. important:: ``get_user_story``/``get_task``/``get_issue``/``get_epic`` and their ``update_*``/``delete_*`` counterparts take a ``project`` (id or slug) and a ``ref`` - the per-project sequential number Taiga @@ -326,6 +330,39 @@ Available tools filter backend supports. Which lookups that includes is a property of the Taiga server you're talking to, not of this client. +.. tip:: Every ``create_*``/``update_*`` tool accepts ``return_representation`` + (``"full"`` default / ``"minimal"`` / ``"none"``), opt-in, to shrink + what a write echoes back: + + - ``"full"`` (default): the complete written resource, exactly as + before. + - ``"minimal"``: ``{"id", "ref" (only if the entity has one), + "version", ...the fields you passed in ``fields``}`` - never the + tool's own required arguments (``subject``, ``status``, etc.), + since you already know those - only ``id``/``ref``/``version`` are + genuinely new information a write produces. + - ``"none"``: ``{"ok": true, "id", "ref" (if any), "version"}`` - a + bare acknowledgement. + + 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 anywhere in this server: if Taiga needs it for optimistic + locking, pass it inside ``fields`` yourself, exactly as today. + +.. tip:: ``update_work_items(project, updates, return_representation="full")`` + updates a batch of user stories/tasks/issues/epics in one call. + Each entry in ``updates`` is + ``{"entity_type": "user_story"|"task"|"issue"|"epic", "ref": , + "fields": {...}}``. **Not atomic** - items are processed in order, + each succeeds or fails independently, and one failure never rolls + 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 + aren't supported here (no per-project ``ref``) - use + ``update_wiki_page`` directly. + **************** Security notes **************** From bd708ffd72a0042ff3163e6f01b59391a6abe2f2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:17:10 +0200 Subject: [PATCH 41/58] fix(mcp): correct _represent merge order, scope version doc claim, improve batch error messages Addresses final-review findings: `_represent`'s "minimal" branch merged `{**base, **written}`, letting a caller-supplied stale `version` inside `fields` (used for the write's own optimistic locking) silently overwrite the correct post-write version - fixed by reversing the merge order so `base` always wins. `docs/mcp.rst` claimed `version` is "not a separate parameter anywhere in this server", which is false - `set_custom_attribute_value` has one - scoped the claim to `create_*`/`update_*` tools. `update_work_items`'s error rows used bare `str(exc)`, producing opaque messages like `"'wiki'"` for structural failures (unsupported entity_type, missing keys) - now includes the exception type name. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 7 +++++-- taiga/mcp_server/server.py | 4 ++-- tests/test_mcp_server.py | 29 ++++++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index 66da78d..152bb70 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -347,8 +347,11 @@ 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 anywhere in this server: if Taiga needs it for optimistic - locking, pass it inside ``fields`` yourself, exactly as today. + 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.) .. tip:: ``update_work_items(project, updates, return_representation="full")`` updates a batch of user stories/tasks/issues/epics in one call. diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 59928c1..0e2b782 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -82,7 +82,7 @@ def _represent( if hasattr(resource, "ref"): base["ref"] = resource.ref if return_representation == "minimal": - return {**base, **written} + return {**written, **base} return {"ok": True, **base} @@ -1052,7 +1052,7 @@ def update_work_items( "status": "error", "entity_type": item.get("entity_type"), "ref": item.get("ref"), - "error": str(exc), + "error": f"{type(exc).__name__}: {exc}", } ) return results diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index efa92fa..09207b7 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1619,6 +1619,14 @@ def test_represent_minimal_omits_ref_when_resource_has_none(): assert result == {"id": 1, "version": 5, "content": "Updated"} +def test_represent_minimal_version_from_resource_wins_over_stale_written_version(): + resource = MagicMock(id=1, version=8, ref=42) + + result = server._represent(resource, {"subject": "Updated", "version": 7}, "minimal") + + assert result == {"id": 1, "version": 8, "ref": 42, "subject": "Updated"} + + def test_represent_none_includes_only_ok_id_ref_version(): resource = MagicMock(id=1, version=5, ref=42) @@ -1784,7 +1792,12 @@ def test_update_work_items_mixed_success_and_failure(mock_get_client): result = server.update_work_items(1, updates) assert result[0] == {"id": 1, "subject": "US updated"} - assert result[1] == {"status": "error", "entity_type": "task", "ref": 20, "error": "boom"} + assert result[1] == { + "status": "error", + "entity_type": "task", + "ref": 20, + "error": "Exception: boom", + } mock_us.patch.assert_called_once_with(["subject"], subject="US updated") @@ -1805,6 +1818,20 @@ def test_update_work_items_return_representation_minimal(mock_get_client): mock_client.user_stories.get.assert_not_called() +@patch("taiga.mcp_server.server.get_client") +def test_update_work_items_unsupported_entity_type_produces_error_row(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_get_client.return_value = mock_client + + updates = [{"entity_type": "wiki", "ref": 5, "fields": {"content": "x"}}] + + result = server.update_work_items(1, updates) + + assert result == [{"status": "error", "entity_type": "wiki", "ref": 5, "error": "KeyError: 'wiki'"}] + + @patch("taiga.mcp_server.server.get_client") def test_update_work_items_malformed_item_does_not_abort_batch(mock_get_client): mock_client = MagicMock() From 4314a91ec1de481456688341087aba2b7c12bd6a Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:26:02 +0200 Subject: [PATCH 42/58] docs(changes): add towncrier fragment for write-response payload-reduction feature --- changes/+mcp-write-payload-reduction.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/+mcp-write-payload-reduction.feature diff --git a/changes/+mcp-write-payload-reduction.feature b/changes/+mcp-write-payload-reduction.feature new file mode 100644 index 0000000..c263997 --- /dev/null +++ b/changes/+mcp-write-payload-reduction.feature @@ -0,0 +1 @@ +Add opt-in `return_representation` (`full`/`minimal`/`none`) to the MCP server's write tools, and a new `update_work_items` batch-write tool, shrinking write-response payloads and collapsing many-item write sequences into one call while leaving default behaviour byte-for-byte unchanged. From 46a38b1d8102ff777ecee6945937bf344de28959 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:40:40 +0200 Subject: [PATCH 43/58] fix(mcp): read membership display name from full_name, not full_name_display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_assigned_users always returned null names — confirmed against a live Taiga instance that membership records expose the display name as full_name, not full_name_display (the field name every other Taiga user block in this codebase uses, which this code wrongly assumed memberships shared too). The output key stays full_name_display for consistency with those other blocks; only the source field read from the membership record changes. Also corrects an unrelated test's example field name (list_memberships' generic fields-projection test) to use a real membership field instead of the same wrong assumption. --- taiga/mcp_server/server.py | 11 +++++++---- tests/test_mcp_server.py | 18 +++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 0e2b782..a61d188 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -376,9 +376,12 @@ def _resolve_assigned_users( """Attach `assigned_users_extra_info` (id + full_name_display) to each item's bare `assigned_users` id list, resolving names via each distinct project's memberships. - Memberships share the same `full_name_display` field every other Taiga user block in - this codebase uses (`owner_extra_info`, `assigned_to_extra_info`, ...). Fetches at most - one membership page (up to 100 members) per distinct project id actually referenced. + Membership records use `full_name` for the display name, not the `full_name_display` + 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. """ items = data if isinstance(data, list) else [data] if not any(item.get("assigned_users") for item in items): @@ -392,7 +395,7 @@ def _resolve_assigned_users( 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_display") for m in memberships} + membership_maps[project_id] = {m["user"]: m.get("full_name") for m in memberships} 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 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 09207b7..9a3f021 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1196,13 +1196,13 @@ def test_list_memberships_fields(mock_get_client): mock_client.projects.get.return_value = mock_project mock_client.projects.get_by_slug.return_value = mock_project mock_project.list_memberships.return_value = [ - {"user": 10, "full_name_display": "Alice", "photo": "https://example.com/a.png"} + {"user": 10, "full_name": "Alice", "photo": "https://example.com/a.png"} ] mock_get_client.return_value = mock_client - result = server.list_memberships(1, fields=["user", "full_name_display"]) + result = server.list_memberships(1, fields=["user", "full_name"]) - assert result == [{"user": 10, "full_name_display": "Alice"}] + assert result == [{"user": 10, "full_name": "Alice"}] @patch("taiga.mcp_server.server.get_client") @@ -1448,8 +1448,8 @@ def test_resolve_assigned_users_attaches_names_from_project_memberships(mock_get mock_project = MagicMock() mock_client.projects.get.return_value = mock_project mock_project.list_memberships.return_value = [ - {"user": 10, "full_name_display": "Alice"}, - {"user": 11, "full_name_display": "Bob"}, + {"user": 10, "full_name": "Alice"}, + {"user": 11, "full_name": "Bob"}, ] mock_get_client.return_value = mock_client @@ -1470,7 +1470,7 @@ def test_resolve_assigned_users_fetches_memberships_once_per_distinct_project(mo mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_project.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] mock_get_client.return_value = mock_client data = [ @@ -1488,7 +1488,7 @@ def test_resolve_assigned_users_unmatched_id_gets_none_name(mock_get_client): mock_client = MagicMock() mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_project.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] mock_get_client.return_value = mock_client data = {"id": 1, "project": 5, "assigned_users": [10, 99]} @@ -1523,7 +1523,7 @@ def test_list_user_stories_resolve_assigned_users(mock_get_client): mock_client.user_stories.list.return_value = [{"id": 1, "project": 5, "assigned_users": [10]}] mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_project.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] mock_get_client.return_value = mock_client result = server.list_user_stories(resolve_assigned_users=True) @@ -1556,7 +1556,7 @@ def test_list_user_stories_resolve_assigned_users_survives_payload_minimal(mock_ mock_client.user_stories.list.return_value = [{"id": 1, "project": 5, "assigned_users": [10]}] mock_project = MagicMock() mock_client.projects.get.return_value = mock_project - mock_project.list_memberships.return_value = [{"user": 10, "full_name_display": "Alice"}] + mock_project.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] mock_get_client.return_value = mock_client result = server.list_user_stories(resolve_assigned_users=True, payload="minimal") From 806b72699153189a8d16852cc8796f41c06bee49 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:46:42 +0200 Subject: [PATCH 44/58] docs(changes): add towncrier fragment for resolve_assigned_users field-name fix --- changes/+mcp-resolve-assigned-users-field-name-fix.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/+mcp-resolve-assigned-users-field-name-fix.bugfix diff --git a/changes/+mcp-resolve-assigned-users-field-name-fix.bugfix b/changes/+mcp-resolve-assigned-users-field-name-fix.bugfix new file mode 100644 index 0000000..5114829 --- /dev/null +++ b/changes/+mcp-resolve-assigned-users-field-name-fix.bugfix @@ -0,0 +1 @@ +Fix `resolve_assigned_users` always returning `null` display names by reading the correct `full_name` field from Taiga membership records instead of the nonexistent `full_name_display`. From 83b91febb5b9c6323676c6455467f00e885ea10f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:59:17 +0200 Subject: [PATCH 45/58] fix(mcp): improve minimal-field-set accuracy for milestone/userstory/issue - milestone: add project_extra_info.name/.slug so a cross-project report can display/link each board without a second call (M4) - userstory: add assigned_users so minimal never under-reports secondary assignees (M5) - userstory/issue: drop the redundant per-project status id in favour of status_extra_info.name, which is comparable across boards (M8b) - docs: document verified filter keys and the is_closed/ status_extra_info.is_closed contradiction caveat (M11) Ref: artifacts/specs/2026-09-17-taiga-mcp-fix-recommendations.md Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 51 +++++++++++++++++++++++++----- taiga/mcp_server/serialize.py | 15 +++++++-- tests/test_mcp_server.py | 7 ++-- tests/test_mcp_server_serialize.py | 6 ++-- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index 152bb70..9782cc1 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -277,18 +277,28 @@ Available tools ``taiga/mcp_server/serialize.py``): - ``milestone`` (``list_milestones``/``get_milestone``): ``id``, - ``name``, ``slug``, ``project``, ``estimated_start``, - ``estimated_finish``, ``closed``. + ``name``, ``slug``, ``project``, ``project_extra_info.name``, + ``project_extra_info.slug``, ``estimated_start``, + ``estimated_finish``, ``closed``. The board's name/slug are + included alongside the bare ``project`` id specifically so a + cross-project report can display and link each board without a + second call. - ``userstory`` (``list_user_stories``/``get_user_story``/ ``get_user_story_by_id``): ``id``, ``ref``, ``subject``, - ``version``, ``milestone``, ``milestone_name``, ``status``, + ``version``, ``milestone``, ``milestone_name``, ``status_extra_info.name``, ``status_extra_info.is_closed``, ``is_closed``, ``finish_date``, ``is_blocked``, - ``assigned_to_extra_info.full_name_display``, ``epics.ref``, - ``assigned_users_extra_info``. + ``assigned_to_extra_info.full_name_display``, + ``assigned_users``, ``epics.ref``, ``assigned_users_extra_info``. + The numeric ``status`` id is deliberately omitted - it is a + per-project id, not comparable across boards, and redundant + alongside ``status_extra_info.name``. ``assigned_users`` (the + bare secondary-assignee id list) is included so ``minimal`` + never silently under-reports who is assigned; pair it with + ``resolve_assigned_users=True`` for names, not just ids. - ``issue`` (``list_issues``/``get_issue``/``get_issue_by_id``): - same as ``userstory`` minus ``assigned_users_extra_info`` - (issues have no ``assigned_users``). + same as ``userstory`` minus ``assigned_users``/ + ``assigned_users_extra_info`` (issues have no ``assigned_users``). - ``epic`` (``list_epics``/``get_epic``/``get_epic_by_id``): ``id``, ``ref``, ``subject``, ``status_extra_info.name``, ``project``. @@ -328,7 +338,32 @@ Available tools False, "estimated_start__lte": "2026-09-20"})`` - already works today with no MCP-side change, for any lookup Taiga's own API filter backend supports. Which lookups that includes is a property - of the Taiga server you're talking to, not of this client. + of the Taiga server you're talking to, not of this client. Filters + verified against a live instance: ``closed`` (milestones), + ``page_size`` (all ``list_*`` tools, caps at 100 per page regardless + of the value requested), ``estimated_start__lte``/ + ``estimated_finish__gte`` (milestones - returns exactly the boards + whose window overlaps the given range), and ``milestone`` as a + single int (user stories, issues). A comma-separated list of ids is + **not** supported the same way for either ``milestone`` (errors) or + ``milestone__in`` (silently ignored, returning an arbitrary unrelated + page of results rather than an error) - do not rely on either form; + fetch each milestone's items with its own call instead. Separately, + ``project=None`` (the default on item-listing tools) already returns + results across every project the caller can see - no project scope + is required for a cross-project item query. + +.. note:: Taiga's own data can disagree with itself on closure state: the + top-level ``is_closed`` field and ``status_extra_info.is_closed`` + are two independently-set signals and have been observed to + contradict each other on the same item (e.g. ``is_closed: true`` + while the status is actually "In progress" and + ``status_extra_info.is_closed: false``). This is a property of the + underlying Taiga data, not a serialization defect in this server. + ``status_extra_info.name``/``status_extra_info.is_closed`` are the + more reliable signals if the two disagree - they come directly from + the status Taiga's UI itself displays, rather than a separately + maintained flag on the item. .. tip:: Every ``create_*``/``update_*`` tool accepts ``return_representation`` (``"full"`` default / ``"minimal"`` / ``"none"``), opt-in, to shrink diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index bd545a9..d2621d8 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -99,7 +99,17 @@ def collapse_extra_info(data: Any) -> Any: MINIMAL_FIELDS: dict[str, list[str]] = { - "milestone": ["id", "name", "slug", "project", "estimated_start", "estimated_finish", "closed"], + "milestone": [ + "id", + "name", + "slug", + "project", + "project_extra_info.name", + "project_extra_info.slug", + "estimated_start", + "estimated_finish", + "closed", + ], "userstory": [ "id", "ref", @@ -107,13 +117,13 @@ def collapse_extra_info(data: Any) -> Any: "version", "milestone", "milestone_name", - "status", "status_extra_info.name", "status_extra_info.is_closed", "is_closed", "finish_date", "is_blocked", "assigned_to_extra_info.full_name_display", + "assigned_users", "epics.ref", "assigned_users_extra_info", ], @@ -124,7 +134,6 @@ def collapse_extra_info(data: Any) -> Any: "version", "milestone", "milestone_name", - "status", "status_extra_info.name", "status_extra_info.is_closed", "is_closed", diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 9a3f021..be9bec0 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1238,6 +1238,7 @@ def test_list_user_stories_payload_minimal_uses_measured_field_set(mock_get_clie "finish_date": None, "is_blocked": False, "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "assigned_users": [10, 11], "epics": [{"ref": 10, "subject": "Epic A"}], "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "y"}, } @@ -1254,12 +1255,12 @@ def test_list_user_stories_payload_minimal_uses_measured_field_set(mock_get_clie "version": 3, "milestone": 7, "milestone_name": "Sprint 1", - "status": 2, "status_extra_info": {"name": "Done", "is_closed": True}, "is_closed": True, "finish_date": None, "is_blocked": False, "assigned_to_extra_info": {"full_name_display": "Bob"}, + "assigned_users": [10, 11], "epics": [{"ref": 10}], } ] @@ -1336,7 +1337,6 @@ def test_get_issue_payload_minimal_uses_measured_field_set(mock_get_client): "version": 1, "milestone": None, "milestone_name": None, - "status": 2, "status_extra_info": {"name": "Done", "is_closed": True}, "is_closed": True, "finish_date": None, @@ -1396,7 +1396,7 @@ def test_get_milestone_payload_minimal_uses_measured_field_set(mock_get_client): "estimated_finish": "2026-09-14", "closed": False, "user_stories": [{"id": 10}], - "project_extra_info": {"id": 5, "name": "Demo", "logo_small_url": "https://x/logo.png"}, + "project_extra_info": {"id": 5, "name": "Demo", "slug": "demo", "logo_small_url": "https://x/logo.png"}, } mock_get_client.return_value = mock_client @@ -1407,6 +1407,7 @@ def test_get_milestone_payload_minimal_uses_measured_field_set(mock_get_client): "name": "Sprint 1", "slug": "sprint-1", "project": 5, + "project_extra_info": {"name": "Demo", "slug": "demo"}, "estimated_start": "2026-09-01", "estimated_finish": "2026-09-14", "closed": False, diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index aef6aea..fcdd049 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -312,6 +312,7 @@ def test_apply_payload_minimal_uses_the_entity_measured_field_set(): "finish_date": None, "is_blocked": False, "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "assigned_users": [10, 11], "epics": [{"ref": 10, "subject": "Epic A"}], "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "y"}, } @@ -325,12 +326,12 @@ def test_apply_payload_minimal_uses_the_entity_measured_field_set(): "version": 3, "milestone": 7, "milestone_name": "Sprint 1", - "status": 2, "status_extra_info": {"name": "Done", "is_closed": True}, "is_closed": True, "finish_date": None, "is_blocked": False, "assigned_to_extra_info": {"full_name_display": "Bob"}, + "assigned_users": [10, 11], "epics": [{"ref": 10}], } @@ -349,6 +350,7 @@ def test_apply_payload_minimal_keeps_assigned_users_extra_info_for_userstory(): "finish_date": None, "is_blocked": False, "assigned_to_extra_info": {"id": 5, "full_name_display": "Bob", "photo": "x"}, + "assigned_users": [10, 11], "epics": [{"ref": 10, "subject": "Epic A"}], "owner_extra_info": {"id": 9, "full_name_display": "Alice", "photo": "y"}, "assigned_users_extra_info": [ @@ -366,12 +368,12 @@ def test_apply_payload_minimal_keeps_assigned_users_extra_info_for_userstory(): "version": 3, "milestone": 7, "milestone_name": "Sprint 1", - "status": 2, "status_extra_info": {"name": "Done", "is_closed": True}, "is_closed": True, "finish_date": None, "is_blocked": False, "assigned_to_extra_info": {"full_name_display": "Bob"}, + "assigned_users": [10, 11], "epics": [{"ref": 10}], "assigned_users_extra_info": [ {"id": 10, "full_name_display": "Alice"}, From b4513991daa19da09ea5f6d4e468572302964624 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 13:59:48 +0200 Subject: [PATCH 46/58] docs(changes): add towncrier fragment for M4/M5/M8/M11 minimal-field-set fixes Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- changes/+mcp-minimal-field-set-fixes.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/+mcp-minimal-field-set-fixes.bugfix diff --git a/changes/+mcp-minimal-field-set-fixes.bugfix b/changes/+mcp-minimal-field-set-fixes.bugfix new file mode 100644 index 0000000..79e7682 --- /dev/null +++ b/changes/+mcp-minimal-field-set-fixes.bugfix @@ -0,0 +1 @@ +Fix the MCP server's `minimal` payload projection: milestones now include `project_extra_info.name`/`.slug`, user stories now include the bare `assigned_users` id list, and both user stories and issues drop the redundant per-project `status` id in favour of the cross-project-comparable `status_extra_info.name`. From efd92cd5d78abc9396c60de10bc77dd9ed40f7d1 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:01:43 +0200 Subject: [PATCH 47/58] docs(mcp): document strict_filters' limits prominently strict_filters only catches this server's own parameter names nested inside filters by mistake. It gives no protection against a misspelled or unsupported Taiga filter key (e.g. milestone__in, silently ignored by Taiga's REST backend) - that class of mistake returns a normal- looking but wrong result set with no error either way. Document this prominently so filter-based narrowing is re-asserted client-side rather than trusted purely because the call didn't raise. Recommendation 2 (echoing the filters Taiga actually applied) was considered and dropped: Taiga's list API gives no such signal back to this client, so the only thing this server could honestly echo is the query it sent, not what Taiga did with it - materially weaker than what the finding asked for, so it's left undone rather than shipped with an implied guarantee it can't deliver. Ref: artifacts/specs/2026-09-17-taiga-mcp-fix-recommendations.md (M10) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/mcp.rst b/docs/mcp.rst index 9782cc1..a377671 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -333,6 +333,18 @@ Available tools unknown query parameters with no error either way, which measured as much as a 24x size regression with no signal that anything went wrong. + **`strict_filters` is a narrow guard, not a filter validator.** It + only catches this server's own parameter names appearing inside + `filters` by mistake. It gives **no protection** against a + misspelled or unsupported Taiga filter key (e.g. `milestone__in`, + which Taiga silently ignores rather than erroring - see the note + below) - that class of mistake returns a normal-looking but wrong + result set with no error either way, `strict_filters` or not. Any + filter-based narrowing this server accepts must be re-asserted + client-side (e.g. checking the returned items' own fields match + what the filter was supposed to select) rather than trusted purely + because the call didn't raise. + .. note:: ``filters`` is forwarded as-is to Taiga's REST endpoint, so server-side filtering - e.g. ``list_milestones(filters={"closed": False, "estimated_start__lte": "2026-09-20"})`` - already works From 9de1354459e0a693a8fc40e76d56c4c342016a53 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:03:56 +0200 Subject: [PATCH 48/58] Release 2.0.0.b5 --- pyproject.toml | 2 +- taiga/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8bc9008..b9cf9b8 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.0b4" +current_version = "2.0.0b5" parse = """(?x) (?P[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index d50e127..eebe7c6 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "2.0.0b4" +__version__ = "2.0.0.b5" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] From 6ab47379258458a3746ab5711c6d9b5d894b56c2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:56:53 +0200 Subject: [PATCH 49/58] fix(mcp): surface real error messages instead of a generic wrapper (N3) Anticipated failures - a strict_filters violation naming the offending keys, a stale/missing version on a single-item update - were reaching callers as a bare "Error executing tool " with no detail. The mcp SDK replaces any exception that isn't its own ToolError with a fixed generic message, treating it as an unanticipated crash; only ToolError (or a ValueError raised inside a pydantic validator) preserves the real text. Add a _patch() helper wrapping every single-item update_*/update_*_by_id tool's resource.patch() call, re-raising as ToolError with the original message, and change _check_strict_filters to raise ToolError directly. update_work_items already avoided this by catching exceptions itself and returning them as structured rows; single-item tools now surface the same detail. Ref: artifacts/specs/verify-writeside-b5.md, artifacts/specs/taiga-mcp-2.0.0b5-verification.md (N1/N3) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/server.py | 41 ++++++++++++++++++++++++++++---------- tests/test_mcp_server.py | 25 +++++++++++++++++++++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index a61d188..5a038c3 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -7,6 +7,7 @@ from typing import Any, Literal from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from .auth import get_client from .serialize import apply_payload, to_jsonable @@ -86,6 +87,22 @@ def _represent( return {"ok": True, **base} +def _patch(resource: Any, fields: dict[str, Any]) -> None: + """Apply `resource.patch()`, surfacing the real failure message to the caller. + + Any exception here (a stale/missing `version`, a resource that vanished after + lookup, ...) would otherwise reach the caller as a bare "Error executing tool + " - the mcp SDK replaces any exception that isn't its own `ToolError` + with a fixed generic message, deliberately, treating it as an unanticipated + crash. Re-raising as `ToolError` preserves the real message, the same detail + `update_work_items` already surfaces per-row for the same underlying failures. + """ + try: + resource.patch(list(fields.keys()), **fields) + except Exception as exc: + raise ToolError(f"{type(exc).__name__}: {exc}") from exc + + DEFAULT_PAGE_SIZE = 100 @@ -125,12 +142,16 @@ def _check_strict_filters(filters: dict[str, Any] | None, strict_filters: bool) signal either way. Checked against a fixed set of this server's own parameter names, not Taiga's full (and from this client, unknowable) set of real filterable fields, so this can never false-positive on a genuine Taiga filter. + + Raises `ToolError`, not a bare `ValueError` - the mcp SDK discards a bare exception's + message and replaces it with a generic "Error executing tool ", which would + defeat the entire point of naming the offending key(s) here. """ if not strict_filters or not filters: return trapped = _FILTER_TRAP_KEYS & filters.keys() if trapped: - raise ValueError( + raise ToolError( f"filters contains parameter name(s) meant to be passed top-level, not nested: {sorted(trapped)}" ) @@ -522,7 +543,7 @@ def update_user_story( caller's own `fields` (already known). See docs/mcp.rst. """ resource = _get_by_ref("user_story", project, ref) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(get_client().user_stories.get(resource.id)) return _represent(resource, fields, return_representation) @@ -541,7 +562,7 @@ def update_user_story_by_id( """ client = get_client() resource = client.user_stories.get(id) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(client.user_stories.get(id)) return _represent(resource, fields, return_representation) @@ -667,7 +688,7 @@ def update_task( """ # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". resource = _get_by_ref("task", project, ref) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(get_client().tasks.get(resource.id)) return _represent(resource, fields, return_representation) @@ -686,7 +707,7 @@ def update_task_by_id( """ client = get_client() resource = client.tasks.get(id) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(client.tasks.get(id)) return _represent(resource, fields, return_representation) @@ -812,7 +833,7 @@ def update_issue( """ # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". resource = _get_by_ref("issue", project, ref) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(get_client().issues.get(resource.id)) return _represent(resource, fields, return_representation) @@ -831,7 +852,7 @@ def update_issue_by_id( """ client = get_client() resource = client.issues.get(id) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(client.issues.get(id)) return _represent(resource, fields, return_representation) @@ -953,7 +974,7 @@ def update_epic( """ # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". resource = _get_by_ref("epic", project, ref) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(get_client().epics.get(resource.id)) return _represent(resource, fields, return_representation) @@ -972,7 +993,7 @@ def update_epic_by_id( """ client = get_client() resource = client.epics.get(id) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(client.epics.get(id)) return _represent(resource, fields, return_representation) @@ -1239,7 +1260,7 @@ def update_wiki_page( # See update_user_story: patch() doesn't refresh the local object, so re-fetch it for "full". client = get_client() resource = client.wikipages.get(id) - resource.patch(list(fields.keys()), **fields) + _patch(resource, fields) if return_representation == "full": return to_jsonable(client.wikipages.get(id)) return _represent(resource, fields, return_representation) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index be9bec0..aa45ab5 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -3,7 +3,9 @@ from unittest.mock import MagicMock, call, patch import pytest +from mcp.server.mcpserver.exceptions import ToolError +from taiga.exceptions import TaigaRestException from taiga.mcp_server import server _HISTORY_ENTRY = { @@ -532,6 +534,25 @@ def test_update_user_story_by_id(mock_get_client): assert result == {"id": 1, "subject": "Updated"} +@patch("taiga.mcp_server.server.get_client") +def test_update_user_story_surfaces_the_real_patch_failure_message(mock_get_client): + # A bare exception from resource.patch() (e.g. a stale `version`) would otherwise reach + # the caller as a generic "Error executing tool update_user_story" with no detail - the + # mcp SDK replaces any exception that isn't its own ToolError with a fixed message. + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_resource = MagicMock(id=1) + mock_resource.patch.side_effect = TaigaRestException( + "url", 400, '{"version": "The version doesn\'t match with the current one"}' + ) + mock_project.get_userstory_by_ref.return_value = mock_resource + mock_get_client.return_value = mock_client + + with pytest.raises(ToolError, match="version doesn't match"): + server.update_user_story(1, 45634, {"subject": "Updated", "version": 7}) + + @patch("taiga.mcp_server.server.get_client") def test_delete_user_story(mock_get_client): mock_client = MagicMock() @@ -1581,7 +1602,7 @@ def test_check_strict_filters_no_op_when_filters_clean(): def test_check_strict_filters_raises_naming_the_trapped_key(): - with pytest.raises(ValueError, match="include_user_stories"): + with pytest.raises(ToolError, match="include_user_stories"): server._check_strict_filters({"include_user_stories": False}, strict_filters=True) @@ -1597,7 +1618,7 @@ def test_list_milestones_strict_filters_false_ignores_trap_key_silently(mock_get def test_list_milestones_strict_filters_true_raises(): - with pytest.raises(ValueError, match="include_user_stories"): + with pytest.raises(ToolError, match="include_user_stories"): server.list_milestones(filters={"include_user_stories": False}, strict_filters=True) From 60445249447850014ed9e23aa6d0464b3dbde767 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:57:31 +0200 Subject: [PATCH 50/58] fix(mcp): collapse invited_by under payload=compact/minimal (M7) collapse_extra_info() only matched keys ending in _extra_info, so invited_by (seen on membership records) - a full nested user block - escaped the same shrinking every other user/project block gets, even though it carries the same id + full_name_display shape. Ref: artifacts/specs/verify-readside-b5.md, artifacts/specs/taiga-mcp-2.0.0b5-verification.md (M7) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/serialize.py | 10 +++++++--- tests/test_mcp_server_serialize.py | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index d2621d8..1689c76 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -73,21 +73,25 @@ def _select_from_item(item: dict[str, Any], paths: list[str]) -> dict[str, Any]: _LABEL_KEYS = ("name", "full_name_display") _EXTRA_INFO_EXTRA_KEEP = {"status_extra_info": ("is_closed",)} +_ADDITIONAL_COLLAPSIBLE_KEYS = frozenset({"invited_by"}) def collapse_extra_info(data: Any) -> Any: - """Shrink every `*_extra_info` block to its id plus whichever descriptive label - field is present. + """Shrink every `*_extra_info` block, plus `invited_by`, to its id plus whichever + descriptive label field is present. Keeps `name` or `full_name_display` (the only two label fields used across owner_extra_info, assigned_to_extra_info, project_extra_info and status_extra_info in this codebase), and additionally `is_closed` for status_extra_info specifically, so a 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. """ if isinstance(data, dict): result: dict[str, Any] = {} for key, value in data.items(): - if key.endswith("_extra_info") and isinstance(value, dict): + 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, ())) result[key] = {k: value[k] for k in keep if k in value} else: diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index fcdd049..8bcc7f9 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -250,6 +250,14 @@ def test_collapse_extra_info_leaves_non_extra_info_keys_untouched(): assert collapse_extra_info(data) == data +def test_collapse_extra_info_collapses_invited_by_despite_the_name_mismatch(): + data = {"invited_by": {"id": 5, "username": "yakky", "full_name_display": "Iacopo Spalletti", "is_active": True}} + + result = collapse_extra_info(data) + + assert result == {"invited_by": {"id": 5, "full_name_display": "Iacopo Spalletti"}} + + # --- apply_payload --------------------------------------------------------------------- From 7d3ab9715afca968286c66b1481d74989e579f12 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:57:56 +0200 Subject: [PATCH 51/58] fix(mcp): strip media from expand'd blocks too (M6) apply_payload() previously merged expand's raw content after the strip pass, guaranteeing an expanded block was byte-identical to the raw resource - including avatar/logo fields, which strip_media otherwise removes from everywhere else in the response. Measured as a 2.81x size penalty in practice, with over half of it one duplicated signed logo URL. Reorders the merge before the strip pass instead: expand still adds back every field of the named block that MINIMAL_FIELDS/fields would otherwise drop, but the block now goes through the same strip_media semantics as the rest of the response. This reverses a Day 1 ruling that took the opposite position; usage measurement since then shows the media leak costs more than the completeness guarantee is worth. Ref: artifacts/specs/verify-readside-b5.md, artifacts/specs/taiga-mcp-2.0.0b5-verification.md (M6) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- taiga/mcp_server/serialize.py | 11 ++++++++--- tests/test_mcp_server.py | 5 ++++- tests/test_mcp_server_serialize.py | 5 ++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index 1689c76..ecbc394 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -175,6 +175,11 @@ def apply_payload( compatibility contract. `MINIMAL_FIELDS` only has measured entries for "milestone", "userstory", "issue" and "epic"; for any other `entity`, `payload="minimal"` falls back to the same projection as `payload="compact"`. + + `expand` merges each named key's full, unprojected value from `data` before the strip + pass runs, so an expanded block still loses its avatar/logo fields under the same + `strip_media` semantics as the rest of the response - `expand` widens which fields + come back, not whether media is stripped from them. """ if payload == "full" and fields is None and expand is None and strip_media in (None, False): return data @@ -195,11 +200,11 @@ def apply_payload( else: out = data + if expand: + out = _merge_paths(out, data, expand) + strip = strip_media if strip_media is not None else payload in ("compact", "minimal") if strip: out = strip_avatar_fields(out) - if expand: - out = _merge_paths(out, data, expand) - return out diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index aa45ab5..bc06fd9 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1311,7 +1311,10 @@ def test_get_user_story_expand_adds_full_block_back(mock_get_client): result = server.get_user_story(1, 45634, payload="minimal", expand=["assigned_to_extra_info"]) - assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob", "photo": "x"} + # expand adds back every field of the block ("id" here, absent from MINIMAL_FIELDS), + # but the block still goes through the same strip pass as the rest of the response - + # "photo" is gone even though it was requested to be expanded (M6). + assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob"} @patch("taiga.mcp_server.server.get_client") diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index 8bcc7f9..18a7e4f 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -426,7 +426,10 @@ def test_apply_payload_expand_adds_full_block_back_on_top_of_minimal(): result = apply_payload(data, "userstory", payload="minimal", expand=["assigned_to_extra_info"]) - assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob", "photo": "x"} + # expand adds back every field of the block ("id" here, absent from MINIMAL_FIELDS), + # but the block still goes through the same strip pass as the rest of the response - + # "photo" is gone even though it was requested to be expanded (M6). + assert result["assigned_to_extra_info"] == {"id": 5, "full_name_display": "Bob"} def test_apply_payload_applies_per_item_when_data_is_a_list(): From 492bd3b3c1d603102443172d71f85cf7d3a5e0a2 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:58:28 +0200 Subject: [PATCH 52/58] docs(mcp): document a client-side 'default means required' validation issue Two independent live verification sessions found that some MCP clients reject a tool call outright when a parameter carrying a schema-level default is omitted, even though it is absent from the schema's required array - affecting every such parameter, including ones that predate this server's payload-reduction work (e.g. list_milestones' include_user_stories). Confirmed this server's own schema and its own protocol-level validation are correct (live tools/list shows no required entries for these parameters, and a raw stdio JSON-RPC call omitting them succeeds); the rejection traces to the calling client's own schema-to-validator bridge, not to this server, and there is no hook in this server's dependencies to change what gets emitted to work around it. Ref: artifacts/specs/verify-readside-b5.md, artifacts/specs/taiga-mcp-2.0.0b5-verification.md (B1/N1), artifacts/specs/2026-09-17-b5-verification-spike-report.md Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/mcp.rst b/docs/mcp.rst index a377671..b67ec1c 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -153,6 +153,28 @@ not in any particular project. Check it went through with: claude mcp get taiga +.. warning:: **Some MCP clients require every declared parameter to be passed + explicitly, even ones with a documented default.** Every + parameter below that shows a default (e.g. ``payload="full"``, + ``strict_filters=False``) is genuinely optional in this server's + own JSON schema and its runtime validation - confirmed by + calling the real server directly over the MCP protocol, + omitting those parameters entirely. Some MCP clients have + nonetheless been observed rejecting the call outright + (``-32602``, wording resembling Zod's ``nonoptional`` schema + check) when a parameter carrying a schema-level ``default`` is + omitted - for *every* such parameter, not just the ones this + server added recently: `include_user_stories` on + ``list_milestones`` (which predates this server's payload- + reduction work) triggers the same rejection on an affected + client. This is a property of that client's own schema + validation, not of this server, and there is no available hook + in this server's dependencies to change what gets emitted to + work around it. **If your client exhibits this, pass every + parameter explicitly on every call to an affected tool** - + there is no way to make a client-side check like this + optional from the server side. + **************** Available tools **************** From 96eec52c873a5251a158046b7552f85d71f38ffc Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:58:56 +0200 Subject: [PATCH 53/58] docs(mcp): document that embedded user_stories lack assigned_users (N4) Confirmed via a raw, unfiltered probe: a milestone-embedded user_story has no assigned_users key at all - Taiga's embedded serializer omits it entirely, unlike the top-level list_user_stories/get_user_story shape. Not something fields or resolve_assigned_users can produce, since there's no bare id list in the embedded payload to project or resolve from. The one-call "everything in a sprint" route is real and valuable, but yields primary assignees only. Ref: artifacts/specs/taiga-mcp-2.0.0b5-verification.md (N4) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index b67ec1c..cd4259f 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -267,7 +267,13 @@ Available tools List/get milestones (sprints), optionally scoped to a project (``list_milestones`` only). Each milestone embeds its full ``user_stories`` - pass ``include_user_stories=False`` to strip that (potentially large) field from the - result. + result. This embedded route is the cheapest way to get every story in a sprint in + one call (pair it with ``fields=["id", "user_stories.ref", ...]`` to shrink it + further), but Taiga's embedded story serializer carries only the single primary + ``assigned_to`` - it has no ``assigned_users`` key at all, so multi-assignee data is + unavailable through this route regardless of ``fields``/``resolve_assigned_users``. + For complete multi-assignee data, fetch those stories individually with + ``list_user_stories``/``get_user_story`` instead. ``create_milestone``, ``delete_milestone`` Create/delete milestones (sprints). From c6f3706d16fde14961ec033fa5a7d5c9c5fc7460 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 14:59:17 +0200 Subject: [PATCH 54/58] docs(mcp): document that a list-valued filter silently drops all but the last id (N2) filters={"milestone": [1444, 1446]} returns a small, clean, plausible- looking result set - but only for the last id, with every other id's items silently dropped. Traced to taiga/requestmaker.py: query is passed straight to requests.get(..., params=query), and requests already serializes a list value as repeated same-name query params correctly - this client is not the one collapsing it. The collapse happens in Taiga's own REST backend (standard Django QueryDict.get() semantics on repeated params), outside this repo's control - the only available mitigation is documenting it, alongside the existing milestone__in caveat. Ref: artifacts/specs/taiga-mcp-2.0.0b5-verification.md (N2) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- docs/mcp.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/mcp.rst b/docs/mcp.rst index cd4259f..e846f33 100644 --- a/docs/mcp.rst +++ b/docs/mcp.rst @@ -388,7 +388,18 @@ Available tools **not** supported the same way for either ``milestone`` (errors) or ``milestone__in`` (silently ignored, returning an arbitrary unrelated page of results rather than an error) - do not rely on either form; - fetch each milestone's items with its own call instead. Separately, + fetch each milestone's items with its own call instead. A Python + *list* value (e.g. ``filters={"milestone": [1444, 1446]}``) is a + third failure mode, and the most dangerous of the three: it returns + a small, clean, plausible-looking result set - but only for the + *last* id in the list, with every other id's items silently + dropped. This client sends the list correctly (as repeated query + parameters, standard ``requests`` behaviour); the collapse happens + in Taiga's own REST backend, which appears to read only the last + value of a repeated parameter (standard Django ``QueryDict.get()`` + semantics) - nothing on this client's side can change that. Do not + pass a list as a filter value for any key; fetch each id with its + own call instead. Separately, ``project=None`` (the default on item-listing tools) already returns results across every project the caller can see - no project scope is required for a cross-project item query. From a1f32473608d96207875fa7db2d9487f9b5f87af Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Thu, 17 Sep 2026 15:03:11 +0200 Subject: [PATCH 55/58] chore: release 2.0.0b6 --- pyproject.toml | 2 +- taiga/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9cf9b8..72ca971 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.0b5" +current_version = "2.0.0b6" parse = """(?x) (?P[0-9]+) \\.(?P[0-9]+) diff --git a/taiga/__init__.py b/taiga/__init__.py index eebe7c6..e0aa5b8 100644 --- a/taiga/__init__.py +++ b/taiga/__init__.py @@ -6,7 +6,7 @@ Taiga Python API library """ -__version__ = "2.0.0.b5" +__version__ = "2.0.0.b6" __author__ = "Nephila" __license__ = "MIT" __all__ = ["TaigaAPI"] From 9eb1f281874d22cac89eabaed700d1c9cb05c23e Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 15:11:56 +0200 Subject: [PATCH 56/58] docs(changes): add towncrier fragments for N3/M7/M6 bugfixes Fragments for the three b6 bugfix commits (060e3a4, 4901a62, 40b8f94) that were missed at the time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QBEycLqZR68HpZF5jCdq7Y --- changes/+mcp-expand-strips-media.bugfix | 1 + changes/+mcp-invited-by-collapse.bugfix | 1 + changes/+mcp-surface-real-error-messages.bugfix | 1 + 3 files changed, 3 insertions(+) create mode 100644 changes/+mcp-expand-strips-media.bugfix create mode 100644 changes/+mcp-invited-by-collapse.bugfix create mode 100644 changes/+mcp-surface-real-error-messages.bugfix diff --git a/changes/+mcp-expand-strips-media.bugfix b/changes/+mcp-expand-strips-media.bugfix new file mode 100644 index 0000000..8c7d0d8 --- /dev/null +++ b/changes/+mcp-expand-strips-media.bugfix @@ -0,0 +1 @@ +Fix `expand` reintroducing avatar/logo fields that `strip_media` otherwise removes from the rest of the response - an expanded block now goes through the same strip pass as everything else. diff --git a/changes/+mcp-invited-by-collapse.bugfix b/changes/+mcp-invited-by-collapse.bugfix new file mode 100644 index 0000000..063ee4a --- /dev/null +++ b/changes/+mcp-invited-by-collapse.bugfix @@ -0,0 +1 @@ +Fix `invited_by` (on membership records) escaping the `payload="compact"`/`"minimal"` collapsing every other user/project block gets, since it doesn't end in `_extra_info`. diff --git a/changes/+mcp-surface-real-error-messages.bugfix b/changes/+mcp-surface-real-error-messages.bugfix new file mode 100644 index 0000000..421d115 --- /dev/null +++ b/changes/+mcp-surface-real-error-messages.bugfix @@ -0,0 +1 @@ +Fix the MCP server swallowing anticipated failure messages (a `strict_filters` violation, a stale/missing `version` on a single-item `update_*` call) into a generic "Error executing tool" with no detail. These now surface the real message, matching what `update_work_items` already returned per-row for the same failures. From 903cce4159b0970429dc1e5190dbc5f34932c92f Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 15:49:03 +0200 Subject: [PATCH 57/58] test(mcp): cover minimal/none write representations and assigned-users resolution Co-Authored-By: Claude Sonnet 5.5 --- tests/test_mcp_server.py | 134 +++++++++++++++++++++++++++++ tests/test_mcp_server_serialize.py | 18 ++++ 2 files changed, 152 insertions(+) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index bc06fd9..238b547 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1881,3 +1881,137 @@ def test_update_work_items_malformed_item_does_not_abort_batch(mock_get_client): assert result[1]["ref"] == 20 assert isinstance(result[1]["error"], str) and result[1]["error"] mock_us.patch.assert_called_once_with(["subject"], subject="US updated") + + +# --- return_representation on remaining write tools ------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task_by_id_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_client.tasks.get.return_value = MagicMock(id=1, version=2, ref=9) + mock_get_client.return_value = mock_client + + result = server.update_task_by_id(1, {"status": 5}, return_representation="minimal") + + assert result == {"id": 1, "version": 2, "ref": 9, "status": 5} + mock_client.tasks.get.assert_called_once_with(1) + + +@patch("taiga.mcp_server.server.get_client") +def test_create_issue_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_client.issues.create.return_value = MagicMock(id=3, version=1, ref=11) + mock_get_client.return_value = mock_client + + result = server.create_issue(1, "New", 2, 3, 4, 5, fields={"description": "d"}, return_representation="minimal") + + assert result == {"description": "d", "id": 3, "version": 1, "ref": 11} + + +@patch("taiga.mcp_server.server.get_client") +def test_update_issue_by_id_return_representation_none(mock_get_client): + mock_client = MagicMock() + mock_client.issues.get.return_value = MagicMock(id=3, version=4, ref=12) + mock_get_client.return_value = mock_client + + result = server.update_issue_by_id(3, {"status": 5}, return_representation="none") + + assert result == {"ok": True, "id": 3, "version": 4, "ref": 12} + mock_client.issues.get.assert_called_once_with(3) + + +@patch("taiga.mcp_server.server.get_client") +def test_update_epic_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_epic_by_ref.return_value = MagicMock(id=4, version=1, ref=70) + mock_get_client.return_value = mock_client + + result = server.update_epic(1, 70, {"subject": "S"}, return_representation="minimal") + + assert result == {"subject": "S", "id": 4, "version": 1, "ref": 70} + mock_client.epics.get.assert_not_called() + + +@patch("taiga.mcp_server.server.get_client") +def test_update_epic_by_id_return_representation_none(mock_get_client): + mock_client = MagicMock() + mock_client.epics.get.return_value = MagicMock(id=4, version=1, ref=70) + mock_get_client.return_value = mock_client + + result = server.update_epic_by_id(4, {"subject": "S"}, return_representation="none") + + assert result == {"ok": True, "id": 4, "version": 1, "ref": 70} + mock_client.epics.get.assert_called_once_with(4) + + +@patch("taiga.mcp_server.server.get_client") +def test_create_wiki_page_return_representation_minimal(mock_get_client): + mock_client = MagicMock() + mock_client.wikipages.create.return_value = MagicMock(id=8, version=1, spec=["id", "version"]) + mock_get_client.return_value = mock_client + + result = server.create_wiki_page(1, "home", "Welcome", fields={"x": 1}, return_representation="minimal") + + assert result == {"x": 1, "id": 8, "version": 1} + + +# --- resolve_assigned_users on get_user_story* ------------------------------------------- + + +@patch("taiga.mcp_server.server.get_client") +def test_get_user_story_resolves_assigned_users(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_project.get_userstory_by_ref.return_value = {"id": 1, "project": 5, "assigned_users": [10]} + mock_project.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] + mock_get_client.return_value = mock_client + + result = server.get_user_story(5, 1, resolve_assigned_users=True) + + assert result["assigned_users_extra_info"] == [{"id": 10, "full_name_display": "Alice"}] + + +@patch("taiga.mcp_server.server.get_client") +def test_get_user_story_by_id_resolves_assigned_users(mock_get_client): + mock_client = MagicMock() + mock_project = MagicMock() + mock_client.projects.get.return_value = mock_project + mock_client.user_stories.get.return_value = {"id": 1, "project": 5, "assigned_users": [10]} + mock_project.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] + mock_get_client.return_value = mock_client + + result = server.get_user_story_by_id(1, resolve_assigned_users=True) + + assert result["assigned_users_extra_info"] == [{"id": 10, "full_name_display": "Alice"}] + + +@patch("taiga.mcp_server.server.get_client") +def test_resolve_assigned_users_skips_items_without_assignees(mock_get_client): + mock_client = MagicMock() + mock_client.projects.get.return_value.list_memberships.return_value = [{"user": 10, "full_name": "Alice"}] + mock_get_client.return_value = mock_client + + data = [{"id": 1, "project": 5, "assigned_users": []}, {"id": 2, "project": 5, "assigned_users": [10]}] + + result = server._resolve_assigned_users(data) + + assert "assigned_users_extra_info" not in result[0] + assert result[1]["assigned_users_extra_info"] == [{"id": 10, "full_name_display": "Alice"}] + + +@patch("taiga.mcp_server.server.get_client") +def test_update_task_return_representation_none(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=2, version=3, ref=45) + mock_get_client.return_value = mock_client + + result = server.update_task(1, 45, {"status": 5}, return_representation="none") + + assert result == {"ok": True, "id": 2, "version": 3, "ref": 45} + mock_client.tasks.get.assert_not_called() diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index 18a7e4f..3deda8f 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -438,3 +438,21 @@ def test_apply_payload_applies_per_item_when_data_is_a_list(): result = apply_payload(data, "userstory", fields=["id", "subject"]) assert result == [{"id": 1, "subject": "a"}, {"id": 2, "subject": "b"}] + + +def test_to_jsonable_falls_back_to_str_for_unknown_types(): + assert to_jsonable(object) == str(object) + + +def test_select_fields_returns_scalars_unchanged(): + assert select_fields("text", ["a"]) == "text" + + +def test_select_fields_passes_non_dict_list_items_through(): + assert select_fields([1, {"a": 1, "b": 2}], ["a"]) == [1, {"a": 1}] + + +def test_merge_paths_ignores_keys_missing_from_source(): + from taiga.mcp_server.serialize import _merge_paths + + assert _merge_paths({"a": 1}, {"b": 2}, ["b", "missing"]) == {"a": 1, "b": 2} From 16acdb7bbeba3ef34b7b77b1a2726d5eb8e7ee84 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 4 Oct 2026 16:33:44 +0200 Subject: [PATCH 58/58] fix(mcp): don't derive default media stripping from payload when fields is set Co-Authored-By: Claude Sonnet 5.5 --- taiga/mcp_server/serialize.py | 5 ++++- taiga/mcp_server/server.py | 6 ++++-- tests/test_mcp_server_serialize.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/taiga/mcp_server/serialize.py b/taiga/mcp_server/serialize.py index ecbc394..c10ada2 100644 --- a/taiga/mcp_server/serialize.py +++ b/taiga/mcp_server/serialize.py @@ -203,7 +203,10 @@ def apply_payload( if expand: out = _merge_paths(out, data, expand) - strip = strip_media if strip_media is not None else payload in ("compact", "minimal") + # `fields` overrides `payload` entirely, so the payload-derived default must not apply + # to an explicit field projection; an explicit `strip_media` still wins either way. + default_strip = fields is None and payload in ("compact", "minimal") + strip = strip_media if strip_media is not None else default_strip if strip: out = strip_avatar_fields(out) diff --git a/taiga/mcp_server/server.py b/taiga/mcp_server/server.py index 5a038c3..ce00b1a 100644 --- a/taiga/mcp_server/server.py +++ b/taiga/mcp_server/server.py @@ -310,7 +310,8 @@ def get_history( @mcp.tool() def get_history_by_id( - entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], id: int # noqa: A002 + entity_type: Literal["user_story", "task", "issue", "epic", "wiki"], + id: int, # noqa: A002 ) -> list[dict[str, Any]]: """Get history by database id. @@ -341,7 +342,8 @@ def get_custom_attributes_values( @mcp.tool() def get_custom_attributes_values_by_id( - entity_type: Literal["user_story", "task", "issue", "epic"], id: int # noqa: A002 + entity_type: Literal["user_story", "task", "issue", "epic"], + id: int, # noqa: A002 ) -> dict[str, Any]: """Get custom-attribute values by database id. diff --git a/tests/test_mcp_server_serialize.py b/tests/test_mcp_server_serialize.py index 3deda8f..e21eca7 100644 --- a/tests/test_mcp_server_serialize.py +++ b/tests/test_mcp_server_serialize.py @@ -456,3 +456,13 @@ def test_merge_paths_ignores_keys_missing_from_source(): from taiga.mcp_server.serialize import _merge_paths assert _merge_paths({"a": 1}, {"b": 2}, ["b", "missing"]) == {"a": 1, "b": 2} + + +def test_apply_payload_fields_are_not_media_stripped_by_default_from_payload(): + data = {"id": 1, "owner_extra_info": {"id": 2, "photo": "u"}} + + kept = apply_payload(data, "issue", payload="minimal", fields=["owner_extra_info.photo"]) + stripped = apply_payload(data, "issue", payload="minimal", fields=["owner_extra_info.photo"], strip_media=True) + + assert kept == {"owner_extra_info": {"photo": "u"}} + assert stripped == {"owner_extra_info": {}}