Skip to content

Commit 095978c

Browse files
committed
fix: read Link cursor from OktaAPIResponse in extract_after_cursor
The request-executor path (used by the resilient list_applications/list_group_apps fetch) returns an OktaAPIResponse, which exposes headers via get_headers()/_resp_headers and does not set _next. extract_after_cursor only looked for a .headers attribute, so the Link cursor was never found and pagination stopped after the first page — listings silently truncated to one page (e.g. 20 items) instead of paginating. Resolve headers from .headers, get_headers(), or _resp_headers so the cursor is found regardless of response shape. Adds tests for both executor-response shapes. Refs #48
1 parent 83b44fc commit 095978c

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

src/okta_mcp_server/utils/pagination.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,33 @@ def extract_after_cursor(response) -> Optional[str]:
2525
Returns:
2626
str: The 'after' cursor value, or None if no next page
2727
"""
28-
# --- Okta SDK v3: ApiResponse with Link header ---
29-
if response and hasattr(response, "headers") and response.headers:
28+
# --- Okta SDK v3: Link-header cursor ---
29+
# Resolve a headers mapping from whichever response shape we got:
30+
# * ApiResponse exposes a ``.headers`` attribute
31+
# * OktaAPIResponse (what the request executor returns) exposes headers via
32+
# ``get_headers()`` / ``_resp_headers`` and does NOT have ``.headers``
33+
# Reading both ensures the cursor is found regardless of how the page was
34+
# fetched (typed client vs. raw request executor).
35+
headers = None
36+
if response is not None:
37+
if getattr(response, "headers", None):
38+
headers = response.headers
39+
elif hasattr(response, "get_headers"):
40+
try:
41+
headers = response.get_headers()
42+
except Exception:
43+
headers = None
44+
if not headers and getattr(response, "_resp_headers", None):
45+
headers = response._resp_headers
46+
47+
if headers:
3048
link_header = ""
3149
try:
32-
link_header = response.headers.get("Link", "") or response.headers.get("link", "")
50+
link_header = headers.get("Link", "") or headers.get("link", "")
3351
except Exception:
34-
for key in response.headers:
52+
for key in headers:
3553
if key.lower() == "link":
36-
link_header = response.headers[key]
54+
link_header = headers[key]
3755
break
3856

3957
if link_header and 'rel="next"' in link_header:

tests/test_pagination.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ def test_handles_multiple_link_rels(self):
106106
}
107107
assert extract_after_cursor(response) == "cursor99"
108108

109+
def test_reads_link_from_okta_api_response_get_headers(self):
110+
"""OktaAPIResponse (request-executor result) exposes headers via
111+
get_headers(), not a .headers attribute, and leaves _next unset — the
112+
cursor must still be extracted from the Link header."""
113+
response = MagicMock(spec=["get_headers"])
114+
response.get_headers.return_value = {
115+
"link": '<https://test.okta.com/api/v1/apps?after=execcursor1>; rel="next"'
116+
}
117+
assert extract_after_cursor(response) == "execcursor1"
118+
119+
def test_reads_link_from_resp_headers_attr(self):
120+
"""Fallback to the private _resp_headers mapping when neither .headers
121+
nor get_headers() yields a usable header set."""
122+
response = MagicMock(spec=["_resp_headers"])
123+
response._resp_headers = {
124+
"Link": '<https://test.okta.com/api/v1/apps?after=execcursor2>; rel="next"'
125+
}
126+
assert extract_after_cursor(response) == "execcursor2"
127+
109128

110129
# ---------------------------------------------------------------------------
111130
# extract_after_cursor — SDK v2 path

0 commit comments

Comments
 (0)