Skip to content

Commit f3a73f1

Browse files
author
Jose Luis Moreno
committed
fix: migrate to new Jira /search/jql API endpoints for Cloud only
- Implement Cloud vs Server/DC differentiation in search.py - Cloud: Use new /rest/api/3/search/jql with nextPageToken pagination - Server/DC: Continue using existing /rest/api/*/search endpoints - Handle new Cloud response format (issue IDs vs full objects) - Add ADF description handling in models - Maintain 100% MCP contract compatibility - Apply code formatting and linting fixes - Fixes compatibility with Atlassian API deprecation (Aug 1, 2025) Fixes #658
1 parent 9ad2cbf commit f3a73f1

File tree

6 files changed

+558
-258
lines changed

6 files changed

+558
-258
lines changed

src/mcp_atlassian/jira/epics.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -780,7 +780,9 @@ def _find_sample_epic(self) -> list[dict]:
780780
try:
781781
# Search for issues with type=Epic
782782
jql = "issuetype = Epic ORDER BY updated DESC"
783-
response = self.jira.jql(jql, limit=1)
783+
response = self.jira.post(
784+
"rest/api/3/search/jql", json={"jql": jql, "maxResults": 1}
785+
)
784786
if not isinstance(response, dict):
785787
msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
786788
logger.error(msg)
@@ -811,7 +813,9 @@ def _find_issues_linked_to_epic(self, epic_key: str) -> list[dict]:
811813
f"issueFunction in issuesScopedToEpic('{epic_key}')",
812814
]:
813815
try:
814-
response = self.jira.jql(query, limit=5)
816+
response = self.jira.post(
817+
"rest/api/3/search/jql", json={"jql": query, "maxResults": 5}
818+
)
815819
if not isinstance(response, dict):
816820
msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
817821
logger.error(msg)

src/mcp_atlassian/jira/search.py

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -86,46 +86,65 @@ def search_issues(
8686
fields_param = fields
8787

8888
if self.config.is_cloud:
89+
# Use new /search/jql endpoint with pagination support
90+
all_issues = []
91+
next_page_token = None
8992
actual_total = -1
90-
try:
91-
# Call 1: Get metadata (including total) using standard search API
92-
metadata_params = {"jql": jql, "maxResults": 0}
93-
metadata_response = self.jira.get(
94-
self.jira.resource_url("search"), params=metadata_params
95-
)
96-
97-
if (
98-
isinstance(metadata_response, dict)
99-
and "total" in metadata_response
100-
):
101-
try:
102-
actual_total = int(metadata_response["total"])
103-
except (ValueError, TypeError):
104-
logger.warning(
105-
f"Could not parse 'total' from metadata response for JQL: {jql}. Received: {metadata_response.get('total')}"
106-
)
107-
else:
108-
logger.warning(
109-
f"Could not retrieve total count from metadata response for JQL: {jql}. Response type: {type(metadata_response)}"
110-
)
111-
except Exception as meta_err:
112-
logger.error(
113-
f"Error fetching metadata for JQL '{jql}': {str(meta_err)}"
114-
)
115-
116-
# Call 2: Get the actual issues using the enhanced method
117-
issues_response_list = self.jira.enhanced_jql_get_list_of_tickets(
118-
jql, fields=fields_param, limit=limit, expand=expand
119-
)
12093

121-
if not isinstance(issues_response_list, list):
122-
msg = f"Unexpected return value type from `jira.enhanced_jql_get_list_of_tickets`: {type(issues_response_list)}"
123-
logger.error(msg)
124-
raise TypeError(msg)
94+
while True:
95+
# Prepare request payload for new JQL API
96+
payload = {
97+
"jql": jql,
98+
"fields": fields_param.split(",") if fields_param else [],
99+
"maxResults": min(limit, 100),
100+
}
101+
if expand:
102+
payload["expand"] = expand.split(",")
103+
if next_page_token:
104+
payload["nextPageToken"] = next_page_token
105+
106+
# Call new /search/jql endpoint
107+
response = self.jira.post("rest/api/3/search/jql", json=payload)
108+
109+
if not isinstance(response, dict):
110+
msg = f"Unexpected return value type from search/jql: {type(response)}"
111+
logger.error(msg)
112+
raise TypeError(msg)
113+
114+
# Extract issues and metadata
115+
if "issues" in response:
116+
all_issues.extend(response["issues"])
117+
118+
if actual_total == -1 and "total" in response:
119+
actual_total = response["total"]
120+
121+
# Check for more pages - new API uses isLast instead of nextPageToken
122+
is_last = response.get("isLast", True)
123+
next_page_token = response.get(
124+
"nextPageToken"
125+
) # May not be present
126+
127+
# Break if this is the last page or we've reached the limit
128+
if is_last or len(all_issues) >= limit:
129+
break
130+
131+
# For pagination, we may need to use different approach
132+
# If nextPageToken is not available, we might need to use startAt
133+
if not next_page_token:
134+
# Fallback to startAt-based pagination
135+
current_start = response.get("startAt", len(all_issues))
136+
max_results_per_page = response.get("maxResults", 50)
137+
next_page_token = str(current_start + max_results_per_page)
138+
139+
# Limit results to requested amount
140+
if len(all_issues) > limit:
141+
all_issues = all_issues[:limit]
125142

126143
response_dict_for_model = {
127-
"issues": issues_response_list,
144+
"issues": all_issues,
128145
"total": actual_total,
146+
"maxResults": limit,
147+
"startAt": 0,
129148
}
130149

131150
search_result = JiraSearchResult.from_api_response(
@@ -134,15 +153,19 @@ def search_issues(
134153
requested_fields=fields_param,
135154
)
136155

137-
# Return the full search result object
138156
return search_result
139157
else:
140-
limit = min(limit, 50)
158+
# Server/DC: Keep using old /search endpoint (not deprecated for on-premise)
141159
response = self.jira.jql(
142-
jql, fields=fields_param, start=start, limit=limit, expand=expand
160+
jql=jql,
161+
fields=fields_param,
162+
start=start,
163+
limit=min(limit, 50),
164+
expand=expand,
143165
)
166+
144167
if not isinstance(response, dict):
145-
msg = f"Unexpected return value type from `jira.jql`: {type(response)}"
168+
msg = f"Unexpected return value type from jql: {type(response)}"
146169
logger.error(msg)
147170
raise TypeError(msg)
148171

src/mcp_atlassian/models/jira/issue.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,13 @@ def from_api_response(cls, data: dict[str, Any], **kwargs: Any) -> "JiraIssue":
269269
summary = str(fields.get("summary", EMPTY_STRING))
270270
description = fields.get("description")
271271

272+
# Handle ADF (Atlassian Document Format) description
273+
if isinstance(description, dict):
274+
# Extract plain text from ADF format if possible
275+
description = None # For now, skip complex ADF descriptions
276+
elif description is not None:
277+
description = str(description)
278+
272279
# Timestamps
273280
created = str(fields.get("created", EMPTY_STRING))
274281
updated = str(fields.get("updated", EMPTY_STRING))

src/mcp_atlassian/models/jira/search.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def from_api_response(
3131
) -> "JiraSearchResult":
3232
"""
3333
Create a JiraSearchResult from a Jira API response.
34+
Supports both old and new API response formats.
3435
3536
Args:
3637
data: The search result data from the Jira API
@@ -51,26 +52,39 @@ def from_api_response(
5152
if isinstance(issues_data, list):
5253
for issue_data in issues_data:
5354
if issue_data:
54-
requested_fields = kwargs.get("requested_fields")
55-
issues.append(
56-
JiraIssue.from_api_response(
57-
issue_data, requested_fields=requested_fields
55+
# New API: Check if issue_data is just a string (issue ID)
56+
if isinstance(issue_data, str):
57+
# Create minimal JiraIssue with just the key
58+
issues.append(JiraIssue(key=issue_data))
59+
else:
60+
# Old API or new API with fields: Full issue object
61+
requested_fields = kwargs.get("requested_fields")
62+
issues.append(
63+
JiraIssue.from_api_response(
64+
issue_data, requested_fields=requested_fields
65+
)
5866
)
59-
)
6067

68+
# Handle different response formats between old and new APIs
6169
raw_total = data.get("total")
6270
raw_start_at = data.get("startAt")
6371
raw_max_results = data.get("maxResults")
6472

65-
try:
66-
total = int(raw_total) if raw_total is not None else -1
67-
except (ValueError, TypeError):
68-
total = -1
73+
# New API may not include these fields, especially when empty
74+
# For new API, we need to infer values from available data
75+
if raw_total is None and "isLast" in data:
76+
# New API format - infer total from issues count if isLast=True
77+
total = len(issues) if data.get("isLast", False) else -1
78+
else:
79+
try:
80+
total = int(raw_total) if raw_total is not None else -1
81+
except (ValueError, TypeError):
82+
total = -1
6983

7084
try:
71-
start_at = int(raw_start_at) if raw_start_at is not None else -1
85+
start_at = int(raw_start_at) if raw_start_at is not None else 0
7286
except (ValueError, TypeError):
73-
start_at = -1
87+
start_at = 0
7488

7589
try:
7690
max_results = int(raw_max_results) if raw_max_results is not None else -1

0 commit comments

Comments
 (0)