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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changes/+mcp-payload-reduction.feature
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Add opt-in payload-reduction parameters (`payload`, `fields`, `strip_media`, `expand`, `resolve_assigned_users`, `strict_filters`) to the MCP server's read tools, shrinking oversized responses on request while leaving default behaviour byte-for-byte unchanged.
Add opt-in payload-reduction parameters (`payload`, `fields`, `strip_media`, `expand`, `resolve_assigned_users`, `strict_filters`) to the MCP server's full-resource `list_*`/`get_*` tools, shrinking oversized responses on request while leaving default behaviour byte-for-byte unchanged. The history and custom-attribute-value read tools are not covered.
1 change: 1 addition & 0 deletions changes/278.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix `Epic.add_related_user_story` (and so the MCP `link_epic_user_story` tools) never linking a user story to an epic: Taiga requires `epic` in the request body of `related_userstories` and rejected the call with `{"epic": ["This field is required."]}`. The epic id is now always sent.
1 change: 1 addition & 0 deletions changes/278.bugfix.1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make the MCP `link_epic_user_story` and `link_epic_user_story_by_id` tools surface the real failure message instead of a generic "Error executing tool", the same way the single-item `update_*` tools already do.
1 change: 1 addition & 0 deletions changes/278.bugfix.2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop MCP writes from failing when the caller leaves `version` out: the single-item `update_*` tools and `update_work_items` now use the version of the item they have just fetched (an explicit `version` still wins), and `set_custom_attribute_value` / `set_custom_attribute_value_by_id` take an optional `version` that defaults to the current version of the custom-attributes-values resource. `CustomAttributeResource.set_attribute` follows the same default instead of silently sending version 1.
28 changes: 19 additions & 9 deletions docs/mcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,11 @@ Available tools
.. important:: The ``version`` returned by ``get_custom_attributes_values``
(and expected by ``set_custom_attribute_value``) belongs to that
custom-attributes-values resource - a separate version sequence
from the entity's own ``version`` field. Always pass back the
version from a prior ``get_custom_attributes_values`` call (or
``1`` if never set before), not the entity's own ``version``.
from the entity's own ``version`` field. ``version`` is optional
on ``set_custom_attribute_value``: when omitted, the resource's
current version is used. If you pass one, it must come from a
prior ``get_custom_attributes_values`` call, not the entity's own
``version``.

``list_user_stories``, ``get_user_story``, ``create_user_story``, ``update_user_story``, ``delete_user_story``
Manage user stories.
Expand Down Expand Up @@ -433,11 +435,12 @@ Available tools
On ``update_*`` tools, ``"minimal"``/``"none"`` also skip the
re-fetch these tools otherwise perform after writing - a latency
win, not just a smaller response. ``version`` is not a separate
parameter on any ``create_*``/``update_*`` tool: if Taiga needs it
for optimistic locking, pass it inside ``fields`` yourself, exactly
as today. (``set_custom_attribute_value``/``set_custom_attribute_value_by_id``
are the exception - see the note above on their own, unrelated
``version`` sequence.)
parameter on any ``create_*``/``update_*`` tool: if ``fields`` has no
``version``, the tool uses the current version of the item it has
just fetched for optimistic locking; an explicit ``version`` inside
``fields`` wins. (``set_custom_attribute_value``/``set_custom_attribute_value_by_id``
take their own optional ``version`` - see the note above on that
unrelated ``version`` sequence.)

.. tip:: ``update_work_items(project, updates, return_representation="full")``
updates a batch of user stories/tasks/issues/epics in one call.
Expand All @@ -448,9 +451,16 @@ Available tools
back or blocks any other item. Returns one result row per input
item, in the same order (zip ``updates`` with the result to match
them up); a failed item's row is
``{"status": "error", "entity_type", "ref", "error"}``. Wiki pages
``{"status": "error", "entity_type", "ref", "error"}``. If the write
succeeded but the follow-up re-fetch needed for
``return_representation`` failed, the row is instead
``{"status": "updated", "entity_type", "ref", "id",
"readback_error"}`` - the change **was applied**, so don't retry it;
re-read the item to see its current state. Wiki pages
aren't supported here (no per-project ``ref``) - use
``update_wiki_page`` directly.
As with the single-item tools, a missing ``version`` in an item's
``fields`` is filled in from the item just fetched.
Comment thread
yakky marked this conversation as resolved.

****************
Security notes
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ commit = true
message = "Release {new_version}"
commit_args = "--no-verify"
tag = false
current_version = "2.0.0b6"
current_version = "2.0.0.b7"
parse = """(?x)
(?P<major>[0-9]+)
\\.(?P<minor>[0-9]+)
Expand Down
2 changes: 1 addition & 1 deletion taiga/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Taiga Python API library
"""

__version__ = "2.0.0.b6"
__version__ = "2.0.0.b7"
__author__ = "Nephila"
__license__ = "MIT"
__all__ = ["TaigaAPI"]
Expand Down
18 changes: 12 additions & 6 deletions taiga/mcp_server/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def _select_from_item(item: dict[str, Any], paths: list[str]) -> dict[str, Any]:
_ADDITIONAL_COLLAPSIBLE_KEYS = frozenset({"invited_by"})


def collapse_extra_info(data: Any) -> Any:
def collapse_extra_info(data: Any, keep_media: bool = False) -> Any:
"""Shrink every `*_extra_info` block, plus `invited_by`, to its id plus whichever
descriptive label field is present.

Expand All @@ -86,19 +86,22 @@ def collapse_extra_info(data: Any) -> Any:
compact caller can still tell whether an item is closed without expanding the block.
`invited_by` (seen on membership records) is a full nested user block too, but doesn't
end in `_extra_info`, so it needs naming explicitly rather than falling out of the
suffix check.
suffix check. `keep_media=True` also keeps the avatar/logo keys, for callers that
explicitly disabled media stripping.
"""
if isinstance(data, dict):
result: dict[str, Any] = {}
for key, value in data.items():
if (key.endswith("_extra_info") or key in _ADDITIONAL_COLLAPSIBLE_KEYS) and isinstance(value, dict):
keep = ("id", *_LABEL_KEYS, *_EXTRA_INFO_EXTRA_KEEP.get(key, ()))
if keep_media:
keep = (*keep, *_AVATAR_KEYS)
result[key] = {k: value[k] for k in keep if k in value}
else:
result[key] = collapse_extra_info(value)
result[key] = collapse_extra_info(value, keep_media)
return result
if isinstance(data, list):
return [collapse_extra_info(item) for item in data]
return [collapse_extra_info(item, keep_media) for item in data]
return data


Expand Down Expand Up @@ -190,13 +193,16 @@ def apply_payload(
for item in data
]

keep_media = strip_media is False
if fields is not None:
out = select_fields(data, fields)
elif payload == "minimal":
minimal_paths = MINIMAL_FIELDS.get(entity)
out = select_fields(data, minimal_paths) if minimal_paths is not None else collapse_extra_info(data)
out = (
select_fields(data, minimal_paths) if minimal_paths is not None else collapse_extra_info(data, keep_media)
)
elif payload == "compact":
out = collapse_extra_info(data)
out = collapse_extra_info(data, keep_media)
else:
out = data

Expand Down
101 changes: 80 additions & 21 deletions taiga/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ def _get_by_ref(entity_type: str, project: str | int, ref: int) -> Any:
`ref` is the sequential number Taiga shows per project - e.g. the 45634 in
`.../issues/45634` - not the database id used internally for update/delete.
"""
proj = _resolve_project(project)
return _get_by_ref_in(_resolve_project(project), entity_type, ref)


def _get_by_ref_in(proj: Any, entity_type: str, ref: int) -> Any:
"""Like `_get_by_ref`, but for an already-resolved project (avoids a re-fetch per item)."""
return getattr(proj, _REF_METHOD[entity_type])(ref)


Expand All @@ -87,6 +91,18 @@ def _represent(
return {"ok": True, **base}


def _with_version(resource: Any, fields: dict[str, Any]) -> dict[str, Any]:
"""Return `fields` with the resource's current `version` added when the caller omitted it.

The resource was just fetched, so its version is the optimistic-lock token Taiga expects;
leaving it out makes Taiga reject the write. An explicit caller-supplied `version` wins.
"""
version = getattr(resource, "version", None)
if "version" in fields or not isinstance(version, int):
return fields
return {**fields, "version": version}


def _patch(resource: Any, fields: dict[str, Any]) -> None:
"""Apply `resource.patch()`, surfacing the real failure message to the caller.

Expand All @@ -97,6 +113,7 @@ def _patch(resource: Any, fields: dict[str, Any]) -> None:
crash. Re-raising as `ToolError` preserves the real message, the same detail
`update_work_items` already surfaces per-row for the same underlying failures.
"""
fields = _with_version(resource, fields)
try:
resource.patch(list(fields.keys()), **fields)
except Exception as exc:
Expand Down Expand Up @@ -362,14 +379,14 @@ def set_custom_attribute_value(
ref: int,
attribute_id: int,
value: Any,
version: int,
version: int | None = None,
) -> dict[str, Any]:
"""Set one custom-attribute value on a user story, task, issue or epic,
identified by its per-project ref number. `attribute_id` is the numeric id
from get_project's `*_custom_attributes` list (e.g. the "Code" attribute).
`version` is the custom-attributes-values resource's own version (from a
prior get_custom_attributes_values call, or 1 if never set before) - not
the entity's own `version` field.
`version` is optional: when omitted, the custom-attributes-values resource's
current version is used. If given, it is that resource's own version (from a
prior get_custom_attributes_values call) - not the entity's own `version` field.
"""
resource = _get_by_ref(entity_type, project, ref)
return to_jsonable(resource.set_attribute(attribute_id, value, version=version))
Expand All @@ -381,7 +398,7 @@ def set_custom_attribute_value_by_id(
id: int, # noqa: A002
attribute_id: int,
value: Any,
version: int,
version: int | None = None,
) -> dict[str, Any]:
"""Set a custom-attribute value by database id.

Expand All @@ -393,6 +410,18 @@ def set_custom_attribute_value_by_id(
return to_jsonable(resource.set_attribute(attribute_id, value, version=version))


def _membership_names(proj: Any, wanted: set[int]) -> dict[int, str | None]:
"""Map user id -> full_name over a project's memberships, paging until `wanted` is covered."""
names: dict[int, str | None] = {}
page = 1
while True:
batch = to_jsonable(proj.list_memberships(page=page, page_size=DEFAULT_PAGE_SIZE))
names.update({m["user"]: m.get("full_name") for m in batch})
if len(batch) < DEFAULT_PAGE_SIZE or wanted <= names.keys():
return names
page += 1


def _resolve_assigned_users(
data: dict[str, Any] | list[dict[str, Any]],
) -> dict[str, Any] | list[dict[str, Any]]:
Expand All @@ -403,8 +432,8 @@ def _resolve_assigned_users(
field seen on other Taiga user blocks (`owner_extra_info`, `assigned_to_extra_info`,
...) - confirmed against a live instance. We still key our own output as
`full_name_display`, for consistency with those other blocks; only the source field
read from the membership record differs. Fetches at most one membership page (up to
100 members) per distinct project id actually referenced.
read from the membership record differs. Pages through each distinct referenced
project's memberships until every assigned user id is found or the pages run out.
"""
items = data if isinstance(data, list) else [data]
if not any(item.get("assigned_users") for item in items):
Expand All @@ -417,8 +446,8 @@ def _resolve_assigned_users(
continue
project_id = item["project"]
if project_id not in membership_maps:
memberships = to_jsonable(client.projects.get(project_id).list_memberships(**_paginated({})))
membership_maps[project_id] = {m["user"]: m.get("full_name") for m in memberships}
wanted = {uid for it in items if it.get("project") == project_id for uid in it.get("assigned_users") or []}
membership_maps[project_id] = _membership_names(client.projects.get(project_id), wanted)
name_map = membership_maps[project_id]
item["assigned_users_extra_info"] = [
{"id": uid, "full_name_display": name_map.get(uid)} for uid in assigned_users
Expand Down Expand Up @@ -1016,13 +1045,21 @@ def delete_epic_by_id(id: int) -> dict[str, str]: # noqa: A002
return {"status": "deleted", "id": str(id)}


def _link_epic(epic: Any, user_story_id: int) -> dict[str, Any]:
"""Link a user story to an epic, surfacing the real failure message to the caller (see `_patch`)."""
try:
return to_jsonable(epic.add_related_user_story(user_story_id))
except Exception as exc:
raise ToolError(f"{type(exc).__name__}: {exc}") from exc


@mcp.tool()
def link_epic_user_story(project: str | int, epic_ref: int, user_story_ref: int) -> dict[str, Any]:
"""Link a user story to an epic, identifying both by their per-project ref numbers."""
proj = _resolve_project(project)
epic = proj.get_epic_by_ref(epic_ref)
user_story = proj.get_userstory_by_ref(user_story_ref)
return to_jsonable(epic.add_related_user_story(user_story.id))
return _link_epic(epic, user_story.id)


@mcp.tool()
Expand All @@ -1033,7 +1070,7 @@ def link_epic_user_story_by_id(epic_id: int, user_story_id: int) -> dict[str, An
"""
client = get_client()
epic = client.epics.get(epic_id)
return to_jsonable(epic.add_related_user_story(user_story_id))
return _link_epic(epic, user_story_id)


@mcp.tool()
Expand All @@ -1047,8 +1084,8 @@ def update_work_items(
Each entry in `updates` is `{"entity_type": "user_story"|"task"|"issue"|"epic", "ref": <int>,
"fields": {...}}` - `ref` is the per-project ref number (as in `update_user_story` etc.), and
`fields` is the dict of attributes to change, exactly as a single-item `update_*` call would
take it. If Taiga needs a `version` for optimistic locking, put it inside that item's own
`fields`, same as today's single-item contract - this tool adds no new version handling.
take it. If `version` is omitted from an item's `fields`, the fetched item's current version
is used for optimistic locking; an explicit `version` wins.

Not atomic: items are processed in order, each succeeds or fails independently, and a
failure does not roll back or block any other item. Returns one result row per input item,
Expand All @@ -1058,32 +1095,54 @@ def update_work_items(

Wiki pages are not supported here - they have no per-project ref number, and aren't part of
the sprint-rollover workflow this tool targets. Use `update_wiki_page` directly.

If the write succeeds but the follow-up re-fetch for `return_representation` fails, the row is
`{"status": "updated", "entity_type": ..., "ref": ..., "id": ..., "readback_error": "<message>"}`
- the change was applied, so do not retry it.
"""
results: list[dict[str, Any]] = []
proj = None
for item in updates:
try:
entity_type = item["entity_type"]
ref = item["ref"]
fields = item["fields"]
resource = _get_by_ref(entity_type, project, ref)
resource.patch(list(fields.keys()), **fields)
if proj is None: # Resolved lazily so a lookup failure becomes an error row, not an abort.
proj = _resolve_project(project)
resource = _get_by_ref_in(proj, entity_type, ref)
patch_fields = _with_version(resource, fields)
resource.patch(list(patch_fields.keys()), **patch_fields)
except Exception as exc: # A single bad item must not abort the rest of the batch.
results.append(_batch_error(item, exc))
continue
try:
if return_representation == "full":
client_attr = getattr(get_client(), _ENTITY_ATTR[entity_type])
results.append(to_jsonable(client_attr.get(resource.id)))
else:
results.append(_represent(resource, fields, return_representation))
except Exception as exc: # A single bad item must not abort the rest of the batch.
except Exception as exc: # The write already succeeded: report it as such, not as a failure.
results.append(
{
"status": "error",
"entity_type": item.get("entity_type"),
"ref": item.get("ref"),
"error": f"{type(exc).__name__}: {exc}",
"status": "updated",
"entity_type": entity_type,
"ref": ref,
"id": resource.id,
"readback_error": f"{type(exc).__name__}: {exc}",
}
)
return results


def _batch_error(item: dict[str, Any], exc: Exception) -> dict[str, Any]:
return {
"status": "error",
"entity_type": item.get("entity_type"),
"ref": item.get("ref"),
"error": f"{type(exc).__name__}: {exc}",
}


# --- Milestones (sprints) -----------------------------------------------------------------


Expand Down
14 changes: 10 additions & 4 deletions taiga/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,19 @@ class CustomAttributeResource(InstanceResource):
CustomAttributeResource base class
"""

def set_attribute(self, id, value, version=1): # noqa: A002
def set_attribute(self, id, value, version=None): # noqa: A002
"""
Set attribute to a specific value

:param id: id of the attribute
:param value: value of the attribute
:param version: version of the attribute (default = 1)
:param version: version of the custom-attributes-values resource (default: its current version)
"""
attributes = self._get_attributes(cache=True)
# An omitted version is derived from the fetched values, so it must be fresh: a cached
# copy from an earlier write would carry a stale version after an external edit.
attributes = self._get_attributes(cache=version is not None)
if version is None:
version = attributes.get("version", 1)
formatted_id = f"{id}"
attributes["attributes_values"][formatted_id] = value
response = self.requester.patch(
Expand Down Expand Up @@ -332,10 +336,12 @@ def add_related_user_story(self, user_story_id, **attrs):
"""
Link an existing :class:`UserStory` to this epic.

The API requires ``epic`` in the request body, so it is always sent.

:param user_story_id: id of the :class:`UserStory` to link
:param attrs: other optional attributes of the relation
"""
attrs.update({"user_story": user_story_id})
attrs.update({"user_story": user_story_id, "epic": self.id})
response = self.requester.post(
"/{endpoint}/{id}/related_userstories", endpoint=self.endpoint, id=self.id, payload=attrs
)
Expand Down
Loading
Loading