Repository: sooperset/mcp-atlassian

Issue:
[Bug]:The total number returned by jira_search is incorrect
### Prerequisites

- [x] I have searched the [existing issues](https://github.com/sooperset/mcp-atlassian/issues) to make sure this bug has not already been reported.
- [x] I have checked the [README](https://github.com/sooperset/mcp-atlassian/blob/main/README.md) for relevant information.

### Bug Description

When I use MCP Jira Search, the total returned is not the actual number, but the number of limits I passed in.

![Image](https://github.com/user-attachments/assets/72709ed4-87d9-4352-964e-9c56467edd36)

When I use Jira Web, it shows the correct amount 

![Image](https://github.com/user-attachments/assets/545b5dba-c3c4-4b25-a174-88f46853c8cd)

### Steps to Reproduce

1. Use Cline or other clients
2. Enter prompt: such as assignee = "xx@xx.pet" AND project = "xxxx"
3. The client prompts that jira_search will be used, and the parameters may be: {
"jql": "assignee = \"xx@xx.pet\" AND project = \"xx\"",
"limit": 50
}
4. In the returned data, total is incorrect and is the same as the limit passed in

### Expected Behavior

I expect the total in Jira's return value to be the correct total

### Actual Behavior

```shell
The total actually returned is the limit I passed in.
```

### mcp-atlassian Version

v0.8.3

### Installation Method

Other

### Operating System

macOS

### Python Version

3.13.3

### Atlassian Instance Type

Jira Cloud

### Client Application

cline

### Additional Context

_No response_

Discussion:
@sooperset: Hi @dalianzhu,

Thanks for the detailed bug report!  I've identified and fixed the issue where `total` was incorrectly set to match the `limit` value with PR #360.

The issue stemmed from `atlassian-python-api`'s Cloud `enhanced_jql` endpoint not returning pagination metadata (`total`, `startAt`, `maxResults`), combined with our `JiraSearchResult` validator incorrectly using the page's issue count as fallback values.

I've updated `JiraSearchResult` to remove the problematic validator logic and return `-1` for missing metadata fields to explicitly indicate "unknown" values, while maintaining existing behavior for Server/DC environments.

I'am sticking with `atlassian-python-api` as it handles JQL consistently across Server/DC environments and most MCP Jira tools are built on it. Direct REST implementation would need significant refactoring, which we'll consider if accurate pagination metadata becomes a critical MCP requirement.

Let us know if you have any questions! @sooperset: Thanks to PR #360 and PR #369 by @swapnilsurdi, we can now get the correct total count and fetch more than 50 issues from Jira Cloud searches!

<img width="331" alt="Image" src="https://github.com/user-attachments/assets/e5ee994e-b9ea-47b1-b40f-a0481147c9f7" />

Solution patch:
diff --git a/src/mcp_atlassian/jira/search.py b/src/mcp_atlassian/jira/search.py
index d2e1d46..502d271 100644
--- a/src/mcp_atlassian/jira/search.py
+++ b/src/mcp_atlassian/jira/search.py
@@ -83,18 +83,50 @@ class SearchMixin(JiraClient, IssueOperationsProto):
                 fields_param = fields
 
             if self.config.is_cloud:
-                response = self.jira.enhanced_jql_get_list_of_tickets(
+                actual_total = -1
+                try:
+                    # Call 1: Get metadata (including total) using standard search API
+                    metadata_params = {"jql": jql, "maxResults": 0}
+                    metadata_response = self.jira.get(
+                        self.jira.resource_url("search"), params=metadata_params
+                    )
+
+                    if (
+                        isinstance(metadata_response, dict)
+                        and "total" in metadata_response
+                    ):
+                        try:
+                            actual_total = int(metadata_response["total"])
+                        except (ValueError, TypeError):
+                            logger.warning(
+                                f"Could not parse 'total' from metadata response for JQL: {jql}. Received: {metadata_response.get('total')}"
+                            )
+                    else:
+                        logger.warning(
+                            f"Could not retrieve total count from metadata response for JQL: {jql}. Response type: {type(metadata_response)}"
+                        )
+                except Exception as meta_err:
+                    logger.error(
+                        f"Error fetching metadata for JQL '{jql}': {str(meta_err)}"
+                    )
+
+                # Call 2: Get the actual issues using the enhanced method
+                issues_response_list = self.jira.enhanced_jql_get_list_of_tickets(
                     jql, fields=fields_param, limit=limit, expand=expand
                 )
 
-                if not isinstance(response, list):
-                    msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
+                if not isinstance(issues_response_list, list):
+                    msg = f"Unexpected return value type from `jira.enhanced_jql_get_list_of_tickets`: {type(issues_response_list)}"
                     logger.error(msg)
                     raise TypeError(msg)
 
-                # Convert the response to a search result model
+                response_dict_for_model = {
+                    "issues": issues_response_list,
+                    "total": actual_total,
+                }
+
                 search_result = JiraSearchResult.from_api_response(
-                    {"issues": response[0:limit], "total": len(response[0:limit])},
+                    response_dict_for_model,
                     base_url=self.config.url,
                     requested_fields=fields_param,
                 )
diff --git a/src/mcp_atlassian/models/jira/search.py b/src/mcp_atlassian/models/jira/search.py
index 6bd8124..d1355dd 100644
--- a/src/mcp_atlassian/models/jira/search.py
+++ b/src/mcp_atlassian/models/jira/search.py
@@ -58,24 +58,24 @@ class JiraSearchResult(ApiModel):
                         )
                     )
 
-        total = data.get("total", 0)
-        start_at = data.get("startAt", 0)
-        max_results = data.get("maxResults", 0)
+        raw_total = data.get("total")
+        raw_start_at = data.get("startAt")
+        raw_max_results = data.get("maxResults")
 
         try:
-            total = int(total) if total is not None else 0
+            total = int(raw_total) if raw_total is not None else -1
         except (ValueError, TypeError):
-            total = 0
+            total = -1
 
         try:
-            start_at = int(start_at) if start_at is not None else 0
+            start_at = int(raw_start_at) if raw_start_at is not None else -1
         except (ValueError, TypeError):
-            start_at = 0
+            start_at = -1
 
         try:
-            max_results = int(max_results) if max_results is not None else 0
+            max_results = int(raw_max_results) if raw_max_results is not None else -1
         except (ValueError, TypeError):
-            max_results = 0
+            max_results = -1
 
         return cls(
             total=total,
@@ -95,12 +95,4 @@ class JiraSearchResult(ApiModel):
         Returns:
             The validated JiraSearchResult instance
         """
-        # Ensure the total is at least as large as the number of issues
-        if self.total < len(self.issues):
-            self.total = len(self.issues)
-
-        # Ensure max_results is at least as large as the number of issues
-        if self.max_results < len(self.issues):
-            self.max_results = len(self.issues)
-
         return self


Test patch:
diff --git a/tests/unit/jira/test_search.py b/tests/unit/jira/test_search.py
index 6ad5fe8..7808cfa 100644
--- a/tests/unit/jira/test_search.py
+++ b/tests/unit/jira/test_search.py
@@ -255,7 +255,7 @@ class TestSearchMixin:
         # Verify results
         assert isinstance(result, JiraSearchResult)
         assert len(result.issues) == 0
-        assert result.total == 0
+        assert result.total == -1
 
     def test_search_issues_with_error(self, search_mixin: SearchMixin):
         """Test search with API error."""
diff --git a/tests/unit/models/test_jira_models.py b/tests/unit/models/test_jira_models.py
index dcb8a2f..8634692 100644
--- a/tests/unit/models/test_jira_models.py
+++ b/tests/unit/models/test_jira_models.py
@@ -941,7 +941,6 @@ class TestJiraIssue:
         simplified = issue.to_simplified_dict()
         assert "timetracking" in simplified
         assert simplified["timetracking"]["original_estimate"] == "1d"
-        assert simplified["timetracking"]["remaining_estimate"] == "4h"
 
         issue.requested_fields = ["summary", "timetracking"]
         simplified = issue.to_simplified_dict()
@@ -973,6 +972,21 @@ class TestJiraSearchResult:
         assert result.max_results == 0
         assert result.issues == []
 
+    def test_from_api_response_missing_metadata(self, jira_search_data):
+        """Test creating a JiraSearchResult when API is missing metadata."""
+        # Remove total, startAt, maxResults from mock data
+        api_data = dict(jira_search_data)
+        api_data.pop("total", None)
+        api_data.pop("startAt", None)
+        api_data.pop("maxResults", None)
+
+        search_result = JiraSearchResult.from_api_response(api_data)
+        # Verify that -1 is used for missing metadata
+        assert search_result.total == -1
+        assert search_result.start_at == -1
+        assert search_result.max_results == -1
+        assert len(search_result.issues) == 1  # Assuming mock data has issues
+
 
 class TestJiraProject:
     """Tests for the JiraProject model."""
diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py
index 808114e..0da7b27 100644
--- a/tests/unit/test_server.py
+++ b/tests/unit/test_server.py
@@ -488,14 +488,31 @@ async def test_list_tools_read_only_mode():
                 ctx.jira,
                 "search_issues",
                 MagicMock(
-                    return_value=[
-                        {
-                            "key": "TEST-123",
-                            "fields": {
-                                "summary": "Test Issue",
-                            },
-                        }
-                    ]
+                    return_value=MagicMock(
+                        total=-1,
+                        start_at=-1,
+                        max_results=-1,
+                        issues=[
+                            MagicMock(
+                                to_simplified_dict=MagicMock(
+                                    return_value={
+                                        "key": "TEST-123",
+                                        "summary": "Test Issue",
+                                    }
+                                )
+                            )
+                        ],
+                        to_simplified_dict=MagicMock(
+                            return_value={
+                                "total": -1,
+                                "start_at": -1,
+                                "max_results": -1,
+                                "issues": [
+                                    {"key": "TEST-123", "summary": "Test Issue"}
+                                ],
+                            }
+                        ),
+                    )
                 ),
             ),
         ),
@@ -535,6 +552,18 @@ async def test_call_tool_success(tool_name, arguments, mock_setup, app_context):
         assert isinstance(result, list)
         assert len(result) > 0
 
+        if tool_name == "jira_search":
+            import json
+
+            from mcp.types import TextContent
+
+            assert isinstance(result[0], TextContent)
+            response_data = json.loads(result[0].text)
+            assert "total" in response_data and response_data["total"] == -1
+            assert "start_at" in response_data and response_data["start_at"] == -1
+            assert "max_results" in response_data and response_data["max_results"] == -1
+            assert "issues" in response_data and len(response_data["issues"]) == 1
+
 
 @pytest.mark.anyio
 async def test_confluence_search_simple_term_uses_sitesearch(app_context):