# Page results and handle errors

> Follow cursors to the end of a list, tell the kinds of error apart, and stay inside the per-minute limits.

Source: https://zorlya.com/guides/developers/page-results-and-handle-errors

Updated 2026-10-25.

To read every result, follow `next_cursor` until `has_more` is false. To handle errors, tell the three kinds apart. To stay inside the limits, send at most 120 reads, 30 writes and 5 deletes a minute for each account. This guide shows each case.

## Lists are paged

Tools that return lists (`search_jobs`, `top_matches`, `list_applications`, `list_saved_jobs`, `list_notifications`, `search_resume`) return a page and say whether there is more:

```json
{ "count": 20, "has_more": true, "next_cursor": "anAxOjIw", "matches": [ … ] }
```

- `has_more: true` means the page is **not** the whole set.
- Pass `next_cursor` back as the `cursor` argument, with the same other arguments, to get the next page.
- Treat a cursor as opaque text. Do not build one or read one.
- `limit` sets the page size. The default is 20 for most lists, 10 for `top_matches` and 5 for `search_resume`. Lists are capped at 50 rows a page.

Two mistakes to avoid:

- **Counting from one page.** "How many remote roles pay over 150,000" cannot be answered from one page. Page to the end, or report the number as a lower bound.
- **Reading a short page as the end.** `search_jobs` applies the `min_score` filter within a page, so a short page with `has_more: true` means the filter thinned it out, not that the results ran out. Look at `has_more`, not at `count`.

A loop that follows the cursor:

```ts
let cursor: string | null = null;
do {
  const page = await callTool('search_jobs', { query: 'python', limit: 50, ...(cursor ? { cursor } : {}) });
  rows.push(...page.jobs);
  cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
```

An empty result is an answer. If `count` is 0 and `has_more` is false, there is nothing to find, and calling again will not change that.

## Three kinds of error

| Kind | How it arrives | Example |
| --- | --- | --- |
| The call was refused before it reached MCP | An HTTP status, usually `401` | A missing, expired or revoked token |
| The message was not valid JSON-RPC | An HTTP `400` or a `200` with an `error` object and a `code` | Bad JSON, a batch, an unknown method |
| A tool ran and failed | A normal `200` with a `result` whose `isError` is `true` | A missing argument, a missing scope, the rate limit |

Check all three. A fourth kind sits in front of them: a `403` with `error="insufficient_scope"` and the `scope` you need, sent when a tool call needs a permission the connection lacks. The last one is the one clients forget: a failed tool call is not a JSON-RPC error, so code that only looks for `error` will treat a failure as a success.

### HTTP 401

The body is `{"error":"invalid_token","error_description":"Authentication required"}` and the `WWW-Authenticate` header carries the address of the resource metadata. Refresh the token and retry once. If the refresh also fails, the person has to sign in again. A suspended account gets the same `401` within an hour of the suspension.

### JSON-RPC errors

| Code | Meaning | Seen when |
| --- | --- | --- |
| `-32700` | Parse error | The body is not JSON. HTTP 400 |
| `-32602` | Invalid params | A batch array, or a body that is not an object (HTTP 400), or a `tools/call` for a tool that is switched off or that this account may never use. That one answers "Tool not available" with the name in `data`, and does not say which of the two it is |
| `-32601` | Method not found | An unknown method. The unknown name is in `data` |
| `-32603` | Internal error | A resource could not be read. The address is in `data` |

Batch requests (a JSON array of messages) are not supported. Send one message per request.

Messages without an `id` are notifications. The server answers them with an empty `202` and never with a body.

### Tool errors

The result looks like this:

```json
{
  "content": [{ "type": "text", "text": "Error executing tool get_job: …" }],
  "isError": true
}
```

The message is written to be read by a person or a model, and it names the fix. The ones you will meet:

- **Unknown tool.** `Unknown tool: name`. The name is wrong, or it is a write tool on a server where writes are off.
- **Invalid arguments.** `Error executing tool get_job: …` with a description of the missing or wrong field. The wording comes from the validator and may change, so show it, do not match on it.
- **Rate limit.** See below.

Prompts report problems as JSON-RPC errors with code `0`, for example `Missing required arguments: {'job_id'}`.

## The rate limit

Calls are counted per account, across all clients, in three separate buckets a minute: 120 reads, 30 writes and 5 deletes. Every request to the MCP server spends from the read bucket first, so listing tools counts too. A tool that writes or deletes then also spends its own bucket, so a loop of reads cannot use up the writes.

Over the read limit, the request itself is refused with `429`, a `Retry-After: 60` header and `error=rate_limited`. Over the write or delete limit, the call returns a tool error that begins `Rate limit reached:` and says to wait about 60 seconds. `similar_jobs` also has its own, smaller allowance, because it compares against every open job, and says so the same way. A support agent's replies and notes also spend the allowance the support app uses.

If the rate limiter itself cannot be reached, the server refuses rather than allow unlimited calls: `503` on a request, or a tool error "The rate limiter is unavailable." Try again later.

The OAuth endpoints have separate limits per address: 30 a minute on `/oauth/authorize`, 60 on `/oauth/token`, 20 on `/oauth/revoke` and 10 on `/oauth/register`. Beyond one they answer `429` with `Retry-After: 60`.

Writes and deletes are written to the request log before they run. If the log cannot be written, the call is refused and nothing changes. Retry later.

To stay under the limit:

- Page with a large `limit` rather than many small pages.
- Cache what does not change, such as `list_tracks` and `list_sources`.
- On a rate limit answer, wait for the time it gives. Do not retry in a loop: a retry spends calls without changing the result.

## What we checked

The error bodies and codes above were produced by calls to a local copy of the server on 2026-10-17. On 2026-10-18 the limits, the `403` step-up and the unavailable-tool answer were updated from the server's source and not run. The page sizes also come from the source. The rate limit answers were not triggered. None of this was run against the production server.
