PrefectHQ/fastmcp · error · ValueError

Invalid cursor: {cursor}

Error message

Invalid cursor: {cursor}

What it means

FastMCP's pagination cursor is an opaque base64-encoded, signed JSON token; `decode` raised this ValueError because the supplied cursor could not be decoded or did not have the expected structure (the underlying JSONDecodeError/KeyError/TypeError/binascii.Error/ValueError is chained). It means the cursor is corrupt, truncated, or was not produced by `encode`/`paginate_sequence`.

Source

Thrown at fastmcp_slim/fastmcp/utilities/pagination.py:47

    @classmethod
    def decode(cls, cursor: str) -> CursorState:
        """Decode cursor from an opaque string.

        Raises:
            ValueError: If the cursor is invalid or malformed.
        """
        try:
            data = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())
            return cls(offset=data["o"])
        except (
            json.JSONDecodeError,
            KeyError,
            ValueError,
            TypeError,
            binascii.Error,
        ) as e:
            raise ValueError(f"Invalid cursor: {cursor}") from e


def paginate_sequence(
    items: Sequence[T],
    cursor: str | None,
    page_size: int,
) -> tuple[list[T], str | None]:
    """Paginate a sequence of items.

    Args:
        items: The full sequence to paginate.
        cursor: Optional cursor from a previous request. None for first page.
        page_size: Maximum number of items per page.

    Returns:
        Tuple of (page_items, next_cursor). next_cursor is None if no more pages.

    Raises:

View on GitHub (pinned to 1f02114297)

Solutions

  1. Discard the bad cursor and restart pagination from the beginning (`cursor=None`) to obtain a fresh valid token.
  2. Ensure the cursor is passed opaquely — no URL decoding/encoding, trimming, or re-serialization between server response and next request.
  3. If cursors were persisted across a FastMCP upgrade, invalidate stored cursors and re-paginate with the current library version.

Example fix

// before
const page = await client.listTools(decodeURIComponent(cursor));
// after (pass opaquely, or restart)
const page = cursor ? await client.listTools(cursor) : await client.listTools();
Defensive patterns

Strategy: try-catch

Validate before calling

def cursor_looks_valid(cursor: str | None) -> bool:
    if cursor is None:
        return True
    import base64, binascii
    try:
        base64.urlsafe_b64decode(cursor)
        return True
    except (binascii.Error, ValueError):
        return False

Type guard

def is_opaque_cursor(v: object) -> bool:
    return isinstance(v, str) and len(v) > 0 and not v.isdigit()

Try / catch

try:
    result = paginate_sequence(items, cursor, page_size)
except ValueError as e:
    if "Invalid cursor" in str(e):
        cursor = None  # restart pagination from the beginning
        result = paginate_sequence(items, cursor, page_size)
    else:
        raise

Prevention

When it happens

Trigger: Passing a cursor string that was hand-crafted, URL-decoded/re-encoded incorrectly, truncated by a client, generated by an older/different FastMCP version with an incompatible format, or any non-cursor garbage string in the `cursor` argument of `paginate_sequence` or a paginated MCP `list_*` call.

Common situations: Clients mangling the opaque token (e.g. treating it as a page number), storing cursors in systems that strip padding or change encoding, upgrading FastMCP so old persisted cursors no longer decode, or users typing cursor values by hand.

Related errors


AI-assisted analysis of PrefectHQ/fastmcp@1f02114297 (2026-08-29). Data as JSON: /api/errors/722b768b0315803a. Report an issue: GitHub.