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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@

## [Unreleased]

### 🚀 Features

- Add zammad_list_tags tool to list all system tags (requires admin.tag permission)
- Add zammad_get_ticket_tags tool to get tags for a specific ticket

## [1.1.0] - 2025-12-09


Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ An MCP server that connects AI assistants to Zammad, providing tools for managin
- `zammad_update_ticket` - Update ticket properties
- `zammad_add_article` - Add comments/notes to tickets
- `zammad_add_ticket_tag` / `zammad_remove_ticket_tag` - Manage ticket tags
- `zammad_get_ticket_tags` - Get tags assigned to a specific ticket
- `zammad_list_tags` - List all tags defined in the system (requires admin.tag permission)

- **Attachment Support**
- `zammad_get_article_attachments` - List attachments for a ticket article
Expand Down
20 changes: 20 additions & 0 deletions mcp_zammad/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,23 @@ def get_article_attachments(self, _ticket_id: int, article_id: int) -> list[dict
article = self.api.ticket_article.find(article_id)
attachments = article.get("attachments", [])
return list(attachments)

def list_tags(self) -> list[dict[str, Any]]:
"""Get all tags defined in the Zammad system.

Uses direct HTTP call via zammad_py's internal session since
the tag_list endpoint is not exposed by the library.

Note:
Requires admin.tag permission.

Returns:
List of tag objects with id, name, and count fields.

Raises:
requests.HTTPError: If the API request fails (e.g., 403 Forbidden)
"""
# Use zammad_py's internal session for authentication
response = self.api.session.get(f"{self.url}/tag_list")
response.raise_for_status()
return list(response.json())
9 changes: 9 additions & 0 deletions mcp_zammad/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ class TagOperationParams(StrictBaseModel):
tag: str = Field(min_length=1, max_length=100, description="Tag name")


class GetTicketTagsParams(StrictBaseModel):
"""Get ticket tags request parameters."""

ticket_id: int = Field(gt=0, description="Ticket ID")
response_format: ResponseFormat = Field(
default=ResponseFormat.MARKDOWN, description="Output format: markdown (default) or json"
)


class GetUserParams(StrictBaseModel):
"""Get user request parameters."""

Expand Down
161 changes: 160 additions & 1 deletion mcp_zammad/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
GetOrganizationParams,
GetTicketParams,
GetTicketStatsParams,
GetTicketTagsParams,
GetUserParams,
Group,
ListParams,
Expand Down Expand Up @@ -2030,7 +2031,7 @@
avg_resolution_time=None,
)

def _setup_system_tools(self) -> None:
def _setup_system_tools(self) -> None: # noqa: PLR0915

Check warning on line 2034 in mcp_zammad/server.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

mcp_zammad/server.py#L2034

ZammadMCPServer._setup_system_tools is too complex (18) (MC0001)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove the unused noqa directive.

Static analysis correctly identifies that PLR0915 is not an enabled rule. The noqa comment serves no purpose.

🧹 Remove unused noqa directive
-    def _setup_system_tools(self) -> None:  # noqa: PLR0915
+    def _setup_system_tools(self) -> None:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _setup_system_tools(self) -> None: # noqa: PLR0915
def _setup_system_tools(self) -> None:
🧰 Tools
🪛 GitHub Check: Codacy Static Code Analysis

[warning] 2034-2034: mcp_zammad/server.py#L2034
ZammadMCPServer._setup_system_tools is too complex (18) (MC0001)

🪛 Ruff (0.14.13)

2034-2034: Unused noqa directive (non-enabled: PLR0915)

Remove unused noqa directive

(RUF100)

🤖 Prompt for AI Agents
In `@mcp_zammad/server.py` at line 2034, The noqa comment on the function
definition _setup_system_tools is unnecessary because PLR0915 isn't enabled;
remove the trailing "  # noqa: PLR0915" from the def _setup_system_tools(self)
-> None: declaration so the function signature no longer contains an unused noqa
directive.

"""Register system information tools."""

@self.mcp.tool(annotations=_read_only_annotations("Get Ticket Statistics"))
Expand Down Expand Up @@ -2288,6 +2289,164 @@

return truncate_response(result)

@self.mcp.tool(annotations=_read_only_annotations("List Tags"))
def zammad_list_tags(params: ListParams) -> str:

Check warning on line 2293 in mcp_zammad/server.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

mcp_zammad/server.py#L2293

Method _setup_system_tools.zammad_list_tags has 76 lines of code (limit is 50)
"""Get all tags defined in the Zammad system.

Args:
params (ListParams): Validated parameters containing:
- response_format (ResponseFormat): Output format (default: MARKDOWN)

Returns:
str: Formatted response with the following schema:

Markdown format (default):
```
# Tag List

Found N tag(s)

- **urgent** (ID: 1, used 15 times)
- **billing** (ID: 2, used 8 times)
- **feature-request** (ID: 3, used 23 times)
```

JSON format:
```json
{
"items": [
{"id": 1, "name": "urgent", "count": 15},
{"id": 2, "name": "billing", "count": 8},
{"id": 3, "name": "feature-request", "count": 23}
],
"total": 3,
"count": 3,
"page": 1,
"per_page": 3,
"has_more": false
}
```

Examples:
- Use when: "List all available tags" -> get tag vocabulary
- Use when: "What tags can I use?" -> get valid tag names
- Use when: "Show me tag options for categorizing tickets"
- Don't use when: Getting tags for a specific ticket (use zammad_get_ticket_tags)

Error Handling:
- Returns "Error: Permission denied" if user lacks admin.tag permission
- Returns "Error: Invalid authentication" on 401 status
- Returns empty list if no tags defined in system

Note:
Requires admin.tag permission (not available to regular agents).
The 'count' field shows how many tickets use each tag.
Use tag 'name' field when adding tags to tickets.
"""
client = self.get_client()
tags = client.list_tags()

# Format response
if params.response_format == ResponseFormat.JSON:
result = json.dumps(
{
"items": tags,
"total": len(tags),
"count": len(tags),
"page": 1,
"per_page": len(tags),
"has_more": False,
},
indent=2,
)
else:
lines = ["# Tag List", "", f"Found {len(tags)} tag(s)", ""]
for tag in tags:
name = tag.get("name", "Unknown")
tag_id = tag.get("id", "?")
count = tag.get("count", 0)
lines.append(f"- **{name}** (ID: {tag_id}, used {count} times)")
result = "\n".join(lines)
Comment on lines +2346 to +2369
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Align list-tags JSON schema with other list tools.
This JSON response omits offset/next_page/next_offset/_meta, which makes it inconsistent with other list endpoints and can surprise clients expecting the standard list schema. Consider reusing the same metadata shape and sorting for stable output.

♻️ Suggested adjustment
-            tags = client.list_tags()
+            tags = client.list_tags()
+            sorted_tags = sorted(tags, key=lambda t: t.get("id", 0))
 
             # Format response
             if params.response_format == ResponseFormat.JSON:
                 result = json.dumps(
                     {
-                        "items": tags,
-                        "total": len(tags),
-                        "count": len(tags),
+                        "items": sorted_tags,
+                        "total": len(sorted_tags),
+                        "count": len(sorted_tags),
                         "page": 1,
-                        "per_page": len(tags),
+                        "per_page": len(sorted_tags),
+                        "offset": 0,
                         "has_more": False,
+                        "next_page": None,
+                        "next_offset": None,
+                        "_meta": {},
                     },
                     indent=2,
                 )
             else:
-                lines = ["# Tag List", "", f"Found {len(tags)} tag(s)", ""]
-                for tag in tags:
+                lines = ["# Tag List", "", f"Found {len(sorted_tags)} tag(s)", ""]
+                for tag in sorted_tags:
                     name = tag.get("name", "Unknown")
                     tag_id = tag.get("id", "?")
                     count = tag.get("count", 0)
                     lines.append(f"- **{name}** (ID: {tag_id}, used {count} times)")
                 result = "\n".join(lines)
🤖 Prompt for AI Agents
In `@mcp_zammad/server.py` around lines 2346 - 2369, The JSON returned by the
list-tags branch (inside the response_format == ResponseFormat.JSON block) uses
a custom schema and lacks the standard list metadata
(offset/next_page/next_offset/_meta) and deterministic ordering; update the code
that builds result after client.list_tags() to sort tags consistently (e.g., by
name or id) and return the standard list schema used by other endpoints: include
items, total, count, page, per_page, has_more plus offset, next_page,
next_offset and a _meta object with the same fields; ensure this change is
applied where params.response_format == ResponseFormat.JSON and uses
get_client() / list_tags() outputs so clients receive the canonical list
response shape.


return truncate_response(result)

@self.mcp.tool(annotations=_read_only_annotations("Get Ticket Tags"))
def zammad_get_ticket_tags(params: GetTicketTagsParams) -> str:

Check warning on line 2374 in mcp_zammad/server.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

mcp_zammad/server.py#L2374

Method _setup_system_tools.zammad_get_ticket_tags has 72 lines of code (limit is 50)
"""Get tags assigned to a specific ticket.

Args:
params (GetTicketTagsParams): Validated parameters containing:
- ticket_id (int): Ticket ID to get tags for
- response_format (ResponseFormat): Output format (default: MARKDOWN)

Returns:
str: Formatted response with the following schema:

Markdown format (default):
```
## Tags for Ticket #123

- urgent
- billing
- follow-up
```

Or if no tags:
```
Ticket #123 has no tags.
```

JSON format:
```json
{
"ticket_id": 123,
"tags": ["urgent", "billing", "follow-up"],
"count": 3
}
```

Examples:
- Use when: "What tags are on ticket 123?" -> ticket_id=123
- Use when: "Show tags for this ticket" -> ticket_id from context
- Use when: "Is ticket 456 tagged as urgent?" -> get tags, check list
- Don't use when: Listing all system tags (use zammad_list_tags)
- Don't use when: Adding/removing tags (use zammad_add_ticket_tag/zammad_remove_ticket_tag)

Error Handling:
- Returns TicketIdGuidanceError if ticket not found
- Returns "Error: Permission denied" if no ticket access
- Returns "Error: Invalid authentication" on 401 status

Note:
Only returns tag names, not full tag metadata.
Use zammad_list_tags to see all available tags with usage counts.
"""
client = self.get_client()
try:
tags = client.get_ticket_tags(params.ticket_id)
except (requests.exceptions.RequestException, ValueError) as e:
_handle_ticket_not_found_error(params.ticket_id, e)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Format response
if params.response_format == ResponseFormat.JSON:
result = json.dumps(
{
"ticket_id": params.ticket_id,
"tags": tags,
"count": len(tags),
},
indent=2,
)
elif not tags:
result = f"Ticket #{params.ticket_id} has no tags."
else:
lines = [f"## Tags for Ticket #{params.ticket_id}", ""]
for tag in tags:
lines.append(f"- {tag}")
result = "\n".join(lines)

return truncate_response(result)

def _setup_resources(self) -> None:
"""Register all resources with the MCP server."""
self._setup_ticket_resource()
Expand Down
55 changes: 55 additions & 0 deletions tests/test_client_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from unittest.mock import Mock, patch

import pytest
import requests

from mcp_zammad.client import ZammadClient

Expand Down Expand Up @@ -478,3 +479,57 @@ def test_update_ticket_group_error_handling(self, mock_zammad_api: Mock) -> None
# This should handle the exception internally and retry
with pytest.raises(Exception, match="Group error"):
client.update_ticket(1, group="Support")

def test_list_tags(self, mock_zammad_api: Mock) -> None:
"""Test list_tags method returns all system tags."""
mock_instance = Mock()
# Mock the session.get for direct HTTP call
mock_response = Mock()
mock_response.json.return_value = [
{"id": 1, "name": "urgent", "count": 15},
{"id": 2, "name": "billing", "count": 8},
{"id": 3, "name": "feature-request", "count": 23},
]
mock_response.raise_for_status = Mock()
mock_instance.session.get.return_value = mock_response
mock_zammad_api.return_value = mock_instance

client = ZammadClient(url="https://test.zammad.com/api/v1", http_token="test-token")

result = client.list_tags()

assert len(result) == 3
assert result[0]["name"] == "urgent"
assert result[0]["count"] == 15
assert result[1]["name"] == "billing"
assert result[2]["name"] == "feature-request"
mock_instance.session.get.assert_called_once_with("https://test.zammad.com/api/v1/tag_list")

def test_list_tags_empty(self, mock_zammad_api: Mock) -> None:
"""Test list_tags returns empty list when no tags defined."""
mock_instance = Mock()
mock_response = Mock()
mock_response.json.return_value = []
mock_response.raise_for_status = Mock()
mock_instance.session.get.return_value = mock_response
mock_zammad_api.return_value = mock_instance

client = ZammadClient(url="https://test.zammad.com/api/v1", http_token="test-token")

result = client.list_tags()

assert result == []
mock_instance.session.get.assert_called_once()

def test_list_tags_permission_denied(self, mock_zammad_api: Mock) -> None:
"""Test list_tags raises error when lacking admin.tag permission."""
mock_instance = Mock()
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.HTTPError("403 Forbidden")
mock_instance.session.get.return_value = mock_response
mock_zammad_api.return_value = mock_instance

client = ZammadClient(url="https://test.zammad.com/api/v1", http_token="test-token")

with pytest.raises(requests.HTTPError, match="403"):
client.list_tags()
Loading