# -*- coding: utf-8 -*-
"""Location: ./mcpgateway/utils/pagination.py
Copyright 2025
SPDX-License-Identifier: Apache-2.0
Authors: Mihai Criveti
Pagination Utilities for MCP Gateway.
This module provides utilities for implementing efficient pagination
across all MCP Gateway endpoints, supporting both offset-based and
cursor-based pagination strategies.
Features:
- Offset-based pagination for simple use cases (<10K records)
- Cursor-based pagination for large datasets (>10K records)
- Automatic strategy selection based on result set size
- Navigation link generation
- Query parameter parsing and validation
Examples:
Basic usage with pagination query::
from mcpgateway.utils.pagination import paginate_query
from sqlalchemy import select
from mcpgateway.common.models import Tool
async def list_tools(db: Session):
query = select(Tool).where(Tool.enabled == True)
result = await paginate_query(
db=db,
query=query,
page=1,
per_page=50,
base_url="/admin/tools"
)
return result
"""
# Standard
import base64
import logging
import math
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlencode
# Third-Party
from fastapi import Request
import orjson
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.sql import Select
# First-Party
from mcpgateway.config import settings
from mcpgateway.schemas import PaginationLinks, PaginationMeta
logger = logging.getLogger(__name__)
def encode_cursor(data: Dict[str, Any]) -> str:
"""Encode pagination cursor data to base64.
Args:
data: Dictionary containing cursor data (id, created_at, etc.)
Returns:
Base64-encoded cursor string
Examples:
>>> cursor_data = {"id": "tool-123", "created_at": "2025-01-15T10:30:00Z"}
>>> cursor = encode_cursor(cursor_data)
>>> isinstance(cursor, str)
True
>>> len(cursor) > 0
True
>>> # Test with simple ID-only cursor
>>> simple_cursor = encode_cursor({"id": 42})
>>> isinstance(simple_cursor, str)
True
>>> len(simple_cursor) > 0
True
>>> # Test empty dict
>>> empty_cursor = encode_cursor({})
>>> isinstance(empty_cursor, str)
True
>>> # Test with numeric values
>>> numeric_cursor = encode_cursor({"id": 12345, "offset": 100})
>>> len(numeric_cursor) > 0
True
"""
json_bytes = orjson.dumps(data, default=str, option=orjson.OPT_SORT_KEYS)
return base64.urlsafe_b64encode(json_bytes).decode()
def decode_cursor(cursor: str) -> Dict[str, Any]:
"""Decode pagination cursor from base64.
Args:
cursor: Base64-encoded cursor string
Returns:
Decoded cursor data dictionary
Raises:
ValueError: If cursor is invalid
Examples:
>>> cursor_data = {"id": "tool-123", "created_at": "2025-01-15T10:30:00Z"}
>>> cursor = encode_cursor(cursor_data)
>>> decoded = decode_cursor(cursor)
>>> decoded["id"]
'tool-123'
>>> # Test round-trip with numeric ID
>>> data = {"id": 42}
>>> encoded = encode_cursor(data)
>>> decoded = decode_cursor(encoded)
>>> decoded["id"]
42
>>> # Test with complex data
>>> complex_data = {"id": "abc-123", "page": 5, "filter": "active"}
>>> encoded_complex = encode_cursor(complex_data)
>>> decoded_complex = decode_cursor(encoded_complex)
>>> decoded_complex["id"]
'abc-123'
>>> decoded_complex["page"]
5
>>> # Test invalid cursor raises ValueError
>>> try:
... decode_cursor("invalid-not-base64")
... except ValueError as e:
... "Invalid cursor" in str(e)
True
>>> # Test empty string raises ValueError
>>> try:
... decode_cursor("")
... except ValueError as e:
... "Invalid cursor" in str(e)
True
"""
try:
json_bytes = base64.urlsafe_b64decode(cursor.encode())
return orjson.loads(json_bytes)
except (ValueError, orjson.JSONDecodeError) as e:
raise ValueError(f"Invalid cursor: {e}")
def generate_pagination_links(
base_url: str,
page: int,
per_page: int,
total_pages: int,
query_params: Optional[Dict[str, Any]] = None,
cursor: Optional[str] = None,
next_cursor: Optional[str] = None,
prev_cursor: Optional[str] = None,
) -> PaginationLinks:
"""Generate pagination navigation links.
Args:
base_url: Base URL for the endpoint
page: Current page number
per_page: Items per page
total_pages: Total number of pages
query_params: Additional query parameters to include
cursor: Current cursor (for cursor-based pagination)
next_cursor: Next page cursor
prev_cursor: Previous page cursor
Returns:
PaginationLinks object with navigation URLs
Examples:
>>> links = generate_pagination_links(
... base_url="/admin/tools",
... page=2,
... per_page=50,
... total_pages=5
... )
>>> "/admin/tools?page=2" in links.self
True
>>> "/admin/tools?page=3" in links.next
True
>>> # Test first page
>>> first_page = generate_pagination_links(
... base_url="/api/resources",
... page=1,
... per_page=25,
... total_pages=10
... )
>>> first_page.prev is None
True
>>> "/api/resources?page=2" in first_page.next
True
>>> # Test last page
>>> last_page = generate_pagination_links(
... base_url="/api/prompts",
... page=5,
... per_page=20,
... total_pages=5
... )
>>> last_page.next is None
True
>>> "/api/prompts?page=4" in last_page.prev
True
>>> # Test cursor-based pagination
>>> cursor_links = generate_pagination_links(
... base_url="/api/tools",
... page=1,
... per_page=50,
... total_pages=0,
... next_cursor="eyJpZCI6MTIzfQ=="
... )
>>> "cursor=" in cursor_links.next
True
>>> "/api/tools?" in cursor_links.next
True
>>> # Test with query parameters
>>> links_with_params = generate_pagination_links(
... base_url="/api/tools",
... page=3,
... per_page=100,
... total_pages=10,
... query_params={"filter": "active", "sort": "name"}
... )
>>> "filter=active" in links_with_params.self
True
>>> "sort=name" in links_with_params.self
True
"""
query_params = query_params or {}
def build_url(page_num: Optional[int] = None, cursor_val: Optional[str] = None) -> str:
"""Build URL with query parameters.
Args:
page_num: Page number for offset pagination
cursor_val: Cursor value for cursor-based pagination
Returns:
str: Complete URL with query parameters
"""
params = query_params.copy()
if cursor_val:
params["cursor"] = cursor_val
params["per_page"] = per_page
elif page_num is not None:
params["page"] = page_num
params["per_page"] = per_page
if params:
return f"{base_url}?{urlencode(params)}"
return base_url
# For cursor-based pagination
if cursor or next_cursor or prev_cursor:
return PaginationLinks(
self=build_url(cursor_val=cursor) if cursor else build_url(page_num=page),
first=build_url(page_num=1),
last=base_url, # Last page not applicable for cursor-based
next=build_url(cursor_val=next_cursor) if next_cursor else None,
prev=build_url(cursor_val=prev_cursor) if prev_cursor else None,
)
# For offset-based pagination
return PaginationLinks(
self=build_url(page_num=page),
first=build_url(page_num=1),
last=build_url(page_num=total_pages) if total_pages > 0 else build_url(page_num=1),
next=build_url(page_num=page + 1) if page < total_pages else None,
prev=build_url(page_num=page - 1) if page > 1 else None,
)
async def offset_paginate(
db: Session,
query: Select,
page: int,
per_page: int,
base_url: str,
query_params: Optional[Dict[str, Any]] = None,
include_links: bool = True,
total_count: Optional[int] = None,
) -> Dict[str, Any]:
"""Paginate query using offset-based pagination.
Best for result sets < 10,000 records.
Args:
db: Database session
query: SQLAlchemy select query
page: Page number (1-indexed)
per_page: Items per page
base_url: Base URL for link generation
query_params: Additional query parameters
include_links: Whether to include navigation links
total_count: Pre-computed total count (avoids duplicate count query)
Returns:
Dictionary with 'data', 'pagination', and 'links' keys
Examples:
Basic offset pagination usage::
from mcpgateway.utils.pagination import offset_paginate
from sqlalchemy import select
from mcpgateway.common.models import Tool
async def list_tools_offset(db: Session, page: int = 1):
query = select(Tool).where(Tool.enabled == True)
result = await offset_paginate(
db=db,
query=query,
page=page,
per_page=50,
base_url="/admin/tools"
)
return result
"""
# Validate parameters
page = max(1, page)
per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
# Get total count (use pre-computed count if provided to avoid duplicate queries)
if total_count is not None:
total_items = total_count
else:
count_query = select(func.count()).select_from(query.alias())
total_items = db.execute(count_query).scalar() or 0
# Calculate pagination metadata
total_pages = math.ceil(total_items / per_page) if total_items > 0 else 0
offset = (page - 1) * per_page
# Validate offset
if offset > settings.pagination_max_offset:
logger.warning(f"Offset {offset} exceeds maximum {settings.pagination_max_offset}")
offset = settings.pagination_max_offset
# Execute paginated query
paginated_query = query.offset(offset).limit(per_page)
items = db.execute(paginated_query).scalars().all()
# Build pagination metadata
pagination = PaginationMeta(
page=page,
per_page=per_page,
total_items=total_items,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1,
next_cursor=None,
prev_cursor=None,
)
# Build links if requested
links = None
if include_links and settings.pagination_include_links:
links = generate_pagination_links(
base_url=base_url,
page=page,
per_page=per_page,
total_pages=total_pages,
query_params=query_params,
)
return {
"data": items,
"pagination": pagination,
"links": links,
}
async def cursor_paginate(
db: Session,
query: Select,
cursor: Optional[str],
per_page: int,
base_url: str,
cursor_field: str = "created_at",
cursor_id_field: str = "id",
query_params: Optional[Dict[str, Any]] = None,
include_links: bool = True,
total_count: Optional[int] = None,
) -> Dict[str, Any]:
"""Paginate query using cursor-based pagination.
Best for result sets > 10,000 records. Uses keyset pagination
for consistent performance regardless of offset.
Args:
db: Database session
query: SQLAlchemy select query
cursor: Current cursor (None for first page)
per_page: Items per page
base_url: Base URL for link generation
cursor_field: Field to use for cursor (default: created_at)
cursor_id_field: ID field for tie-breaking (default: id)
query_params: Additional query parameters
include_links: Whether to include navigation links
total_count: Pre-computed total count (avoids duplicate count query)
Returns:
Dictionary with 'data', 'pagination', and 'links' keys
Examples:
Basic cursor pagination usage::
from mcpgateway.utils.pagination import cursor_paginate
from sqlalchemy import select
from mcpgateway.common.models import Tool
async def list_tools_cursor(db: Session, cursor: Optional[str] = None):
query = select(Tool).order_by(Tool.created_at.desc())
result = await cursor_paginate(
db=db,
query=query,
cursor=cursor,
per_page=50,
base_url="/admin/tools"
)
return result
"""
# Validate parameters
per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
# Store unfiltered query for total count (before cursor filter)
unfiltered_query = query
# Decode cursor if provided
cursor_data = None
if cursor:
try:
cursor_data = decode_cursor(cursor)
except ValueError as e:
logger.warning(f"Invalid cursor: {e}")
cursor_data = None
# Apply cursor filter if provided
if cursor_data:
cursor_value = cursor_data.get(cursor_field)
cursor_id_value = cursor_data.get(cursor_id_field)
if cursor_value and cursor_id_value:
# Parse datetime strings for reliable comparisons
# Standard
from datetime import datetime
if isinstance(cursor_value, str):
try:
cursor_value = datetime.fromisoformat(cursor_value)
except (ValueError, TypeError):
pass # Keep as string if parsing fails
# Extract model class from query to access columns
entities = query.column_descriptions
if entities:
model = entities[0]["entity"]
cursor_col = getattr(model, cursor_field)
id_col = getattr(model, cursor_id_field)
# Keyset pagination for descending order (created_at DESC, id DESC)
# Filter: WHERE created_at < cursor_value OR (created_at = cursor_value AND id < cursor_id)
query = query.where(or_(cursor_col < cursor_value, and_(cursor_col == cursor_value, id_col < cursor_id_value)))
# Fetch one extra item to determine if there's a next page
paginated_query = query.limit(per_page + 1)
items = db.execute(paginated_query).scalars().all()
# Check if there are more items
has_next = len(items) > per_page
if has_next:
items = items[:per_page] # Remove the extra item
# Generate cursors
next_cursor = None
if has_next and items:
last_item = items[-1]
next_cursor = encode_cursor(
{
cursor_field: getattr(last_item, cursor_field, None),
cursor_id_field: getattr(last_item, cursor_id_field, None),
}
)
# Get total count (use pre-computed count if provided to avoid duplicate queries)
# Use unfiltered_query for count so total_items reflects the full dataset, not remaining items
if total_count is not None:
total_items = total_count
else:
count_query = select(func.count()).select_from(unfiltered_query.alias())
total_items = db.execute(count_query).scalar() or 0
# Build pagination metadata
pagination = PaginationMeta(
page=1, # Not applicable for cursor-based
per_page=per_page,
total_items=total_items,
total_pages=0, # Not applicable for cursor-based
has_next=has_next,
has_prev=cursor is not None,
next_cursor=next_cursor,
prev_cursor=None, # Implementing prev cursor requires bidirectional cursors
)
# Build links if requested
links = None
if include_links and settings.pagination_include_links:
links = generate_pagination_links(
base_url=base_url,
page=1,
per_page=per_page,
total_pages=0,
query_params=query_params,
cursor=cursor,
next_cursor=next_cursor,
prev_cursor=None,
)
return {
"data": items,
"pagination": pagination,
"links": links,
}
async def paginate_query(
db: Session,
query: Select,
page: int = 1,
per_page: Optional[int] = None,
cursor: Optional[str] = None,
base_url: str = "",
query_params: Optional[Dict[str, Any]] = None,
use_cursor_threshold: bool = True,
total_count: Optional[int] = None,
) -> Dict[str, Any]:
"""Automatically paginate query using best strategy.
Selects between offset-based and cursor-based pagination
based on result set size and configuration.
Args:
db: Database session
query: SQLAlchemy select query
page: Page number (1-indexed)
per_page: Items per page (uses default if None)
cursor: Cursor for cursor-based pagination
base_url: Base URL for link generation
query_params: Additional query parameters
use_cursor_threshold: Whether to auto-switch to cursor-based
total_count: Pre-computed total count (avoids duplicate count query)
Returns:
Dictionary with 'data', 'pagination', and 'links' keys
Examples:
Automatic pagination with strategy selection::
from mcpgateway.utils.pagination import paginate_query
from sqlalchemy import select
from mcpgateway.common.models import Tool
async def list_tools_auto(db: Session, page: int = 1):
query = select(Tool)
# Automatically switches to cursor-based for large datasets
result = await paginate_query(
db=db,
query=query,
page=page,
base_url="/admin/tools"
)
# Result contains: data, pagination, links
return result
"""
# Use default page size if not provided
if per_page is None:
per_page = settings.pagination_default_page_size
# If cursor is provided, use cursor-based pagination
if cursor and settings.pagination_cursor_enabled:
return await cursor_paginate(
db=db,
query=query,
cursor=cursor,
per_page=per_page,
base_url=base_url,
query_params=query_params,
total_count=total_count,
)
# Check if we should use cursor-based pagination based on total count
if use_cursor_threshold and settings.pagination_cursor_enabled:
# Use pre-computed count if provided, otherwise query for it
if total_count is not None:
total_items = total_count
else:
count_query = select(func.count()).select_from(query.alias())
total_items = db.execute(count_query).scalar() or 0
if total_items > settings.pagination_cursor_threshold:
logger.info(f"Switching to cursor-based pagination (total_items={total_items} > threshold={settings.pagination_cursor_threshold})")
# Pass pre-computed count to cursor_paginate to avoid duplicate query
return await cursor_paginate(
db=db,
query=query,
cursor=cursor,
per_page=per_page,
base_url=base_url,
query_params=query_params,
total_count=total_items,
)
# Pass pre-computed count to offset_paginate to avoid duplicate query
return await offset_paginate(
db=db,
query=query,
page=page,
per_page=per_page,
base_url=base_url,
query_params=query_params,
total_count=total_items,
)
# Use offset-based pagination (no threshold check was performed)
return await offset_paginate(
db=db,
query=query,
page=page,
per_page=per_page,
base_url=base_url,
query_params=query_params,
total_count=total_count,
)
async def unified_paginate(
db: Session,
query: Select,
page: Optional[int] = None,
per_page: Optional[int] = None,
cursor: Optional[str] = None,
limit: Optional[int] = None,
base_url: str = "",
query_params: Optional[Dict[str, Any]] = None,
) -> Union[Dict[str, Any], Tuple[List[Any], Optional[str]]]:
"""Unified pagination helper that returns cursor or page format based on parameters.
This function eliminates duplication in service methods by handling both pagination
styles in one place. It automatically returns the appropriate format:
- Page-based format when `page` is provided: {"data": [...], "pagination": {...}, "links": {...}}
- Cursor-based format when `cursor` or neither is provided: (list, next_cursor)
Args:
db: Database session
query: SQLAlchemy select query (must include ORDER BY for cursor pagination)
page: Page number for page-based pagination (1-indexed)
per_page: Items per page for page-based pagination
cursor: Cursor for cursor-based pagination
limit: Maximum items for cursor-based pagination (overrides default page size)
base_url: Base URL for link generation in page-based mode
query_params: Additional query parameters for links
Returns:
Union[Dict[str, Any], Tuple[List[Any], Optional[str]]]:
If page is provided: Dict with {"data": [...], "pagination": {...}, "links": {...}}
Otherwise: tuple of (list, next_cursor) for backward compatibility
Examples:
>>> import asyncio
>>> from unittest.mock import MagicMock
>>>
>>> # Test cursor-based pagination returns tuple format (list, next_cursor)
>>> async def test_cursor_based():
... mock_db = MagicMock()
... mock_query = MagicMock()
... mock_query.column_descriptions = []
... mock_query.limit = MagicMock(return_value=mock_query)
... mock_db.execute = MagicMock(return_value=MagicMock(
... scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
... ))
... result = await unified_paginate(
... db=mock_db,
... query=mock_query,
... cursor=None,
... limit=50
... )
... return isinstance(result, tuple) and len(result) == 2
>>> asyncio.run(test_cursor_based())
True
>>>
>>> # Verify return type difference: cursor mode returns tuple, page mode returns dict
>>> # Note: Page-based mode testing requires complex SQLAlchemy mocking,
>>> # see unit tests in tests/ for comprehensive page-based pagination tests
"""
# Determine page size
if per_page is None:
per_page = limit if limit and limit > 0 else settings.pagination_default_page_size
# PAGE-BASED PAGINATION
if page is not None:
# Use existing paginate_query for page-based
result = await paginate_query(
db=db,
query=query,
page=page,
per_page=per_page,
base_url=base_url,
query_params=query_params,
use_cursor_threshold=False, # Explicit page-based mode
)
return result
# CURSOR-BASED PAGINATION
# Determine page size from limit parameter
if limit is not None:
if limit == 0:
page_size = None # No limit, fetch all
else:
page_size = min(limit, settings.pagination_max_page_size)
else:
page_size = settings.pagination_default_page_size
# Decode cursor if provided
# Standard
from datetime import datetime
last_id = None
last_created = None
if cursor:
try:
cursor_data = decode_cursor(cursor)
last_id = cursor_data.get("id")
created_str = cursor_data.get("created_at")
if created_str:
last_created = datetime.fromisoformat(created_str)
except (ValueError, TypeError) as e:
logger.warning(f"Invalid cursor, ignoring: {e}")
# Apply cursor filter with keyset pagination (assumes query already has ORDER BY)
if last_id and last_created:
# Extract model from query to apply filters
entities = query.column_descriptions
if entities:
# Third-Party
from sqlalchemy import and_, or_
model = entities[0]["entity"]
# Assumes descending order: created_at DESC, id DESC
query = query.where(or_(model.created_at < last_created, and_(model.created_at == last_created, model.id < last_id)))
# Fetch page_size + 1 to determine if there are more results
if page_size is not None:
query = query.limit(page_size + 1)
items = db.execute(query).scalars().all()
# Check if there are more results
has_more = False
if page_size is not None:
has_more = len(items) > page_size
if has_more:
items = items[:page_size]
# Generate next_cursor if there are more results
next_cursor = None
if has_more and items:
last_item = items[-1]
item_created_at = getattr(last_item, "created_at", None)
item_id = getattr(last_item, "id", None)
# Warn if cursor fields are missing - keyset pagination won't work correctly
if item_id is None:
logger.warning(
f"Cursor pagination may not work correctly: model {type(last_item).__name__} has no 'id' field. "
"Subsequent pages may return duplicate results. Consider using page-based pagination instead."
)
cursor_data = {"created_at": item_created_at, "id": item_id}
# Handle datetime serialization
if cursor_data["created_at"]:
cursor_data["created_at"] = cursor_data["created_at"].isoformat()
next_cursor = encode_cursor(cursor_data)
return (items, next_cursor)
def parse_pagination_params(request: Request) -> Dict[str, Any]:
"""Parse pagination parameters from request.
Args:
request: FastAPI request object
Returns:
Dictionary with parsed pagination parameters
Examples:
>>> from fastapi import Request
>>> # Mock request with query params
>>> request = type('Request', (), {
... 'query_params': {'page': '2', 'per_page': '100'}
... })()
>>> params = parse_pagination_params(request)
>>> params['page']
2
>>> params['per_page']
100
>>> # Test with cursor
>>> request_with_cursor = type('Request', (), {
... 'query_params': {'cursor': 'eyJpZCI6IDEyM30=', 'per_page': '25'}
... })()
>>> params_cursor = parse_pagination_params(request_with_cursor)
>>> params_cursor['cursor']
'eyJpZCI6IDEyM30='
>>> params_cursor['per_page']
25
>>> # Test with sort parameters
>>> request_with_sort = type('Request', (), {
... 'query_params': {'page': '1', 'sort_by': 'name', 'sort_order': 'asc'}
... })()
>>> params_sort = parse_pagination_params(request_with_sort)
>>> params_sort['sort_by']
'name'
>>> params_sort['sort_order']
'asc'
>>> # Test with invalid page (negative) - should default to 1
>>> request_invalid = type('Request', (), {
... 'query_params': {'page': '-5', 'per_page': '50'}
... })()
>>> params_invalid = parse_pagination_params(request_invalid)
>>> params_invalid['page']
1
>>> # Test with no parameters - uses defaults
>>> request_empty = type('Request', (), {'query_params': {}})()
>>> params_empty = parse_pagination_params(request_empty)
>>> params_empty['page']
1
>>> 'cursor' in params_empty
True
>>> 'sort_by' in params_empty
True
"""
page = int(request.query_params.get("page", 1))
per_page = int(request.query_params.get("per_page", settings.pagination_default_page_size))
cursor = request.query_params.get("cursor")
sort_by = request.query_params.get("sort_by", settings.pagination_default_sort_field)
sort_order = request.query_params.get("sort_order", settings.pagination_default_sort_order)
# Validate and constrain values
page = max(1, page)
per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))
return {
"page": page,
"per_page": per_page,
"cursor": cursor,
"sort_by": sort_by,
"sort_order": sort_order,
}