Repository: sooperset/mcp-atlassian

Issue:
[Bug]: cannot paginate on jira_search, getting error on enhanced_jql
### 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 trying to call jira_search tool with pagination the request is sent with start_at but it fails and response includes the following message
```
Error: Error searching issues: The `jql` method is deprecated in Jira Cloud. Use `enhanced_jql` method instead.
```

### Steps to Reproduce

ask to search ticket for a specific condition and limit by 600 with pagination 

### Expected Behavior

Get next page of items

### Actual Behavior

```shell
getting error from MCP
```

### mcp-atlassian Version

0.8.4

### Installation Method

From PyPI (pip install mcp-atlassian / uv add mcp-atlassian)

### Operating System

macOS

### Python Version

3.12.9

### Atlassian Instance Type

Jira Cloud

### Client Application

vs code agent

### Additional Context

_No response_

Discussion:
@OrenMe: from reading the docs this requires migrating to the new cloud based next token search with enhanced_jql 
See https://github.com/atlassian-api/atlassian-python-api/blob/master/examples/jira/jira_search_issues_cloud.py and https://pypi.org/project/atlassian-python-api/@sooperset: Hi @OrenMe, thanks for reporting issue!

You're right about the TypeError when using `jira_search` with pagination in Jira Cloud. The PR #356 addresses this by:

1. Detecting Cloud vs Server/DC environments
2. Using `enhanced_jql` without the `start` parameter for Cloud
3. Keeping the existing approach for Server/DC

This fixes the error, but with an important limitation: in Cloud environments, the `startAt` parameter will be ignored and only the first page of results will be returned.

Complete Cloud pagination support would require additional work to handle `nextPageToken`, which we can consider for a future enhancement.

Thanks again for bringing this to our attention!@OrenMe: Whoo, thanks for the super quick fix
I was trying to create a PR but had issues running it! 
Any chance to add a short section to the guide for how to specifically work inside vscode and add the MCP and run it in local mode, would be happy to contribute back as this is a great and useful MCP@sooperset: Thanks for your interest in contributing!

Here are a couple of options for setting up `mcp-atlassian` locally for VS Code:

### Option 1: Docker Setup
You can build a Docker image and use it as described in the README:
```
docker build -t mcp/atlassian .
```

### Option 2: Local Development Setup with UV
1. Set up your `.env` file with necessary credentials
2. Configure to use your local installation:

```json
{
  "mcpServers": {
    "mcp-atlassian": {
      "command": "uv",
      "args": [
        "--directory",
        "/path/to/your/mcp-atlassian",
        "run",
        "mcp-atlassian"
      ]
    }
  }
}
```
@zparnold: Sounds like this issue is closed @sooperset ?@thesobercoder: > This fixes the error, but with an important limitation: in Cloud environments, the `startAt` parameter will be ignored and only the first page of results will be returned.

Hi @sooperset is this still an open issue? I noticed that while using the cloud environment, the `startAt` is not respected at all the LLM keeps getting duplicate issues.

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8c0f2ef..be0e2ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ description = "The Model Context Protocol (MCP) Atlassian integration is an open
 readme = "README.md"
 requires-python = ">=3.10"
 dependencies = [
-    "atlassian-python-api>=3.41.16",
+    "atlassian-python-api>=4.0.0",
     "beautifulsoup4>=4.12.3",
     "httpx>=0.28.0",
     "mcp>=1.3.0",
diff --git a/src/mcp_atlassian/jira/search.py b/src/mcp_atlassian/jira/search.py
index 0e09935..1a75104 100644
--- a/src/mcp_atlassian/jira/search.py
+++ b/src/mcp_atlassian/jira/search.py
@@ -35,6 +35,8 @@ class SearchMixin(JiraClient, IssueOperationsProto):
             jql: JQL query string
             fields: Fields to return (comma-separated string, list, tuple, set, or "*all")
             start: Starting index if number of issues is greater than the limit
+                  Note: This parameter is ignored in Cloud environments and results will always
+                  start from the first page.
             limit: Maximum issues to return
             expand: Optional items to expand (comma-separated)
             projects_filter: Optional comma-separated list of project keys to filter by, overrides config
@@ -79,9 +81,15 @@ class SearchMixin(JiraClient, IssueOperationsProto):
             else:
                 fields_param = fields
 
-            response = self.jira.jql(
-                jql, fields=fields_param, start=start, limit=limit, expand=expand
-            )
+            if self.config.is_cloud:
+                response = self.jira.enhanced_jql(
+                    jql, fields=fields_param, limit=limit, expand=expand
+                )
+            else:
+                response = self.jira.jql(
+                    jql, fields=fields_param, start=start, limit=limit, expand=expand
+                )
+
             if not isinstance(response, dict):
                 msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
                 logger.error(msg)


Test patch:
diff --git a/tests/unit/jira/test_projects.py b/tests/unit/jira/test_projects.py
index 56c1694..6e765b0 100644
--- a/tests/unit/jira/test_projects.py
+++ b/tests/unit/jira/test_projects.py
@@ -8,6 +8,7 @@ import pytest
 from mcp_atlassian.jira import JiraFetcher
 from mcp_atlassian.jira.config import JiraConfig
 from mcp_atlassian.jira.projects import ProjectsMixin
+from mcp_atlassian.models.jira.issue import JiraIssue
 from mcp_atlassian.models.jira.search import JiraSearchResult
 
 
@@ -153,18 +154,21 @@ def test_get_project_exception(projects_mixin: ProjectsMixin):
 def test_get_project_issues(projects_mixin: ProjectsMixin):
     """Test get_project_issues method."""
     # Setup mock response
-    projects_mixin.jira.jql.return_value = {"issues": []}
+    # Mock the method that is actually called: search_issues
+    # It should return a JiraSearchResult object
+    mock_search_result = JiraSearchResult(
+        issues=[], total=0, start_at=0, max_results=50
+    )
+    projects_mixin.search_issues = MagicMock(return_value=mock_search_result)
 
     # Call the method
     result = projects_mixin.get_project_issues("TEST")
 
-    # Verify JQL query
-    projects_mixin.jira.jql.assert_called_once_with(
+    # Verify search_issues was called, not jira.jql
+    projects_mixin.search_issues.assert_called_once_with(
         "project = TEST",
-        fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype",
         start=0,
         limit=50,
-        expand=None,
     )
     assert isinstance(result, JiraSearchResult)
     assert len(result.issues) == 0
@@ -172,34 +176,34 @@ def test_get_project_issues(projects_mixin: ProjectsMixin):
 
 def test_get_project_issues_with_start(projects_mixin: ProjectsMixin) -> None:
     """Test getting project issues with a start index."""
-    projects_mixin.jira.jql.return_value = {
-        "issues": [
-            {
-                "key": "PROJ-2",
-                "fields": {"summary": "Issue 2"},
-                "self": "https://test.atlassian.net/rest/api/2/issue/10002",
-            }
-        ],
-        "total": 1,
-        "startAt": 3,
-        "maxResults": 5,
-    }
+    # Mock search_issues to return a result reflecting pagination
+    mock_issue = JiraIssue(key="PROJ-2", summary="Issue 2", id="10002")
+    mock_search_result = JiraSearchResult(
+        issues=[mock_issue],
+        total=1,
+        start_at=3,
+        max_results=5,
+    )
+    projects_mixin.search_issues = MagicMock(return_value=mock_search_result)
+
     project_key = "PROJ"
     start_index = 3
 
+    # Call the method
     result = projects_mixin.get_project_issues(project_key, start=start_index, limit=5)
 
     assert len(result.issues) == 1
-    assert result.start_at == 3
-    assert result.max_results == 5
-    assert result.total == 1
+    # Note: Assertions on start_at, max_results, total should be based on the
+    # JiraSearchResult object returned by the mocked search_issues
+    assert result.start_at == 3  # Comes from the mocked JiraSearchResult
+    assert result.max_results == 5  # Comes from the mocked JiraSearchResult
+    assert result.total == 1  # Comes from the mocked JiraSearchResult
 
-    projects_mixin.jira.jql.assert_called_once_with(
+    # Verify search_issues was called with the correct arguments
+    projects_mixin.search_issues.assert_called_once_with(
         f"project = {project_key}",
-        fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype",
         start=start_index,
         limit=5,
-        expand=None,
     )
 
 
@@ -505,30 +509,38 @@ def test_get_project_issues_with_search_mixin(projects_mixin: ProjectsMixin):
 def test_get_project_issues_invalid_response(projects_mixin: ProjectsMixin):
     """Test get_project_issues method with invalid response."""
 
-    # No issues field
-    projects_mixin.jira.jql.return_value = {"issues": []}
+    # Mock search_issues to simulate an empty result scenario
+    mock_search_result = JiraSearchResult(
+        issues=[], total=0, start_at=0, max_results=50
+    )
+    projects_mixin.search_issues = MagicMock(return_value=mock_search_result)
 
     result = projects_mixin.get_project_issues("PROJ1")
     assert result.issues == []
-    projects_mixin.jira.jql.assert_called_once()
+    projects_mixin.search_issues.assert_called_once()
 
-    # Non-dict response
-    projects_mixin.jira.jql.reset_mock()
-    projects_mixin.jira.jql.return_value = "not a dict"
+    # Reset mock and test with non-JiraSearchResult response (this would be handled by the except block)
+    projects_mixin.search_issues.reset_mock()
+    projects_mixin.search_issues = MagicMock(
+        side_effect=TypeError("Not a JiraSearchResult")
+    )
 
     result = projects_mixin.get_project_issues("PROJ1")
     assert result.issues == []
-    projects_mixin.jira.jql.assert_called_once()
+    projects_mixin.search_issues.assert_called_once()
 
 
 def test_get_project_issues_exception(projects_mixin: ProjectsMixin):
     """Test get_project_issues method with exception."""
 
-    projects_mixin.jira.jql.side_effect = Exception("API error")
+    # Mock search_issues to raise an exception, simulating an API error during the search
+    projects_mixin.search_issues = MagicMock(side_effect=Exception("API error"))
 
     result = projects_mixin.get_project_issues("PROJ1")
     assert result.issues == []
-    projects_mixin.jira.jql.assert_called_once()
+    # Verify that search_issues was called, even though it raised an exception
+    # The except block in get_project_issues catches it and returns an empty result
+    projects_mixin.search_issues.assert_called_once()
 
 
 def test_get_project_keys(
diff --git a/tests/unit/jira/test_search.py b/tests/unit/jira/test_search.py
index 0d65eb1..e9de8ae 100644
--- a/tests/unit/jira/test_search.py
+++ b/tests/unit/jira/test_search.py
@@ -1,6 +1,6 @@
 """Tests for the Jira Search mixin."""
 
-from unittest.mock import MagicMock
+from unittest.mock import ANY, MagicMock
 
 import pytest
 import requests
@@ -21,8 +21,96 @@ class TestSearchMixin:
         # Mock methods that are typically provided by other mixins
         mixin._clean_text = MagicMock(side_effect=lambda text: text if text else "")
 
+        # Set config with is_cloud=False by default (Server/DC)
+        mixin.config = MagicMock()
+        mixin.config.is_cloud = False
+        mixin.config.projects_filter = None
+        mixin.config.url = "https://example.atlassian.net"
+
         return mixin
 
+    @pytest.fixture
+    def mock_issues_response(self) -> dict:
+        """Create a mock Jira issues response for testing."""
+        return {
+            "issues": [
+                {
+                    "id": "10001",
+                    "key": "TEST-123",
+                    "fields": {
+                        "summary": "Test issue",
+                        "issuetype": {"name": "Bug"},
+                        "status": {"name": "Open"},
+                        "description": "Test description",
+                        "created": "2024-01-01T10:00:00.000+0000",
+                        "updated": "2024-01-01T11:00:00.000+0000",
+                    },
+                }
+            ],
+            "total": 1,
+            "startAt": 0,
+            "maxResults": 50,
+        }
+
+    @pytest.mark.parametrize(
+        "is_cloud, expected_method_name",
+        [
+            (True, "enhanced_jql"),  # Cloud scenario
+            (False, "jql"),  # Server/DC scenario
+        ],
+    )
+    def test_search_issues_calls_correct_method(
+        self,
+        search_mixin: SearchMixin,
+        mock_issues_response,
+        is_cloud,
+        expected_method_name,
+    ):
+        """Test that the correct Jira API method is called based on Cloud/Server setting."""
+        # Setup: Mock config.is_cloud
+        search_mixin.config.is_cloud = is_cloud
+        search_mixin.config.projects_filter = None  # No filter for this test
+        search_mixin.config.url = (
+            "https://test.example.com"  # Model creation needs this
+        )
+
+        # Setup: Mock response for both API methods
+        search_mixin.jira.enhanced_jql = MagicMock(return_value=mock_issues_response)
+        search_mixin.jira.jql = MagicMock(return_value=mock_issues_response)
+
+        # Determine other method name for assertion
+        other_method_name = (
+            "jql" if expected_method_name == "enhanced_jql" else "enhanced_jql"
+        )
+
+        # Act
+        jql_query = "project = TEST"
+        result = search_mixin.search_issues(jql_query, limit=10, start=0)
+
+        # Assert: Basic result verification
+        assert isinstance(result, JiraSearchResult)
+        assert len(result.issues) > 0  # Based on mocked response
+
+        # Assert: Correct method call verification
+        expected_method_mock = getattr(search_mixin.jira, expected_method_name)
+
+        # Define expected kwargs based on whether it's Cloud or Server
+        expected_kwargs = {
+            "fields": "summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype",
+            "limit": 10,
+            "expand": None,
+        }
+
+        # Add start param only for Server/DC
+        if not is_cloud:
+            expected_kwargs["start"] = 0
+
+        expected_method_mock.assert_called_once_with(jql_query, **expected_kwargs)
+
+        # Assert: Other method was not called
+        other_method_mock = getattr(search_mixin.jira, other_method_name)
+        other_method_mock.assert_not_called()
+
     def test_search_issues_basic(self, search_mixin: SearchMixin):
         """Test basic search functionality."""
         # Setup mock response
@@ -47,7 +135,6 @@ class TestSearchMixin:
             "maxResults": 50,
         }
         search_mixin.jira.jql.return_value = mock_issues
-        search_mixin.config.url = "https://example.atlassian.net"
 
         # Call the method
         result = search_mixin.search_issues("project = TEST")
@@ -346,38 +433,6 @@ class TestSearchMixin:
         assert "assignee" in simplified
         assert simplified["assignee"]["display_name"] == "Test User"
 
-    def test_search_issues_with_start(self, search_mixin: SearchMixin) -> None:
-        """Test searching issues with a start index."""
-        search_mixin.jira.jql.return_value = {
-            "issues": [
-                {
-                    "key": "PROJ-1",
-                    "fields": {"summary": "Issue 1"},
-                    "self": "https://test.atlassian.net/rest/api/2/issue/10001",
-                }
-            ],
-            "total": 1,
-            "startAt": 5,
-            "maxResults": 10,
-        }
-        jql = "project = PROJ"
-        start_index = 5
-
-        result = search_mixin.search_issues(jql, start=start_index, limit=10)
-
-        assert len(result.issues) == 1
-        assert result.start_at == 5
-        assert result.max_results == 10
-        assert result.total == 1
-
-        search_mixin.jira.jql.assert_called_once_with(
-            jql,
-            fields="summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype",
-            start=start_index,
-            limit=10,
-            expand=None,
-        )
-
     def test_get_board_issues(self, search_mixin: SearchMixin):
         """Test get_board_issues method."""
         mock_issues = {
@@ -507,3 +562,104 @@ class TestSearchMixin:
         with pytest.raises(Exception) as e:
             search_mixin.get_sprint_issues("10001")
         assert "API Error content" in str(e.value)
+
+    @pytest.mark.parametrize("is_cloud", [True, False])
+    def test_search_issues_with_projects_filter_jql_construction(
+        self, search_mixin: SearchMixin, mock_issues_response, is_cloud
+    ):
+        """Test that JQL string is correctly constructed when projects_filter is provided."""
+        # Setup
+        search_mixin.config.is_cloud = is_cloud
+        search_mixin.config.projects_filter = (
+            None  # Don't use config filter for this test
+        )
+        search_mixin.config.url = "https://test.example.com"
+
+        # Setup mock response for both API methods
+        search_mixin.jira.enhanced_jql = MagicMock(return_value=mock_issues_response)
+        search_mixin.jira.jql = MagicMock(return_value=mock_issues_response)
+        api_method_mock = getattr(
+            search_mixin.jira, "enhanced_jql" if is_cloud else "jql"
+        )
+
+        # Act: Single project filter
+        search_mixin.search_issues("text ~ 'test'", projects_filter="TEST")
+
+        # Define expected kwargs based on is_cloud
+        expected_kwargs = {
+            "fields": ANY,
+            "limit": ANY,
+            "expand": ANY,
+        }
+        # Add start parameter only for Server/DC
+        if not is_cloud:
+            expected_kwargs["start"] = ANY
+
+        # Assert: JQL verification
+        api_method_mock.assert_called_with(
+            "(text ~ 'test') AND project = TEST",  # Check constructed JQL
+            **expected_kwargs,
+        )
+
+        # Reset mock for next call
+        api_method_mock.reset_mock()
+
+        # Act: Multiple projects filter
+        search_mixin.search_issues("text ~ 'test'", projects_filter="TEST, DEV")
+        # Assert: JQL verification
+        api_method_mock.assert_called_with(
+            '(text ~ \'test\') AND project IN ("TEST", "DEV")',  # Check constructed JQL
+            **expected_kwargs,
+        )
+
+        # Reset mock for next call
+        api_method_mock.reset_mock()
+
+        # Act: Call with both JQL and filter
+        search_mixin.search_issues("project = OTHER", projects_filter="TEST")
+        # Assert: JQL verification (existing JQL has priority)
+        api_method_mock.assert_called_with("project = OTHER", **expected_kwargs)
+
+    @pytest.mark.parametrize("is_cloud", [True, False])
+    def test_search_issues_with_config_projects_filter_jql_construction(
+        self, search_mixin: SearchMixin, mock_issues_response, is_cloud
+    ):
+        """Test that JQL string is correctly constructed when config.projects_filter is used."""
+        # Setup
+        search_mixin.config.is_cloud = is_cloud
+        search_mixin.config.projects_filter = "CONF1,CONF2"  # Set config filter
+        search_mixin.config.url = "https://test.example.com"
+
+        # Setup mock response for both API methods
+        search_mixin.jira.enhanced_jql = MagicMock(return_value=mock_issues_response)
+        search_mixin.jira.jql = MagicMock(return_value=mock_issues_response)
+        api_method_mock = getattr(
+            search_mixin.jira, "enhanced_jql" if is_cloud else "jql"
+        )
+
+        # Define expected kwargs based on is_cloud
+        expected_kwargs = {
+            "fields": ANY,
+            "limit": ANY,
+            "expand": ANY,
+        }
+        # Add start parameter only for Server/DC
+        if not is_cloud:
+            expected_kwargs["start"] = ANY
+
+        # Act: Use config filter
+        search_mixin.search_issues("text ~ 'test'")
+        # Assert: JQL verification
+        api_method_mock.assert_called_with(
+            '(text ~ \'test\') AND project IN ("CONF1", "CONF2")', **expected_kwargs
+        )
+
+        # Reset mock for next call
+        api_method_mock.reset_mock()
+
+        # Act: Override config filter with parameter
+        search_mixin.search_issues("text ~ 'test'", projects_filter="OVERRIDE")
+        # Assert: JQL verification
+        api_method_mock.assert_called_with(
+            "(text ~ 'test') AND project = OVERRIDE", **expected_kwargs
+        )