# How to Implement Pagination in MCP Servers: Python and TypeScript Examples

> Learn to implement pagination in MCP servers using Python and TypeScript. Explore techniques for handling large datasets with limit offset parameters and metadata for LLM navigation.

- Repository: [Composio/awesome-codex-skills](https://github.com/composiohq/awesome-codex-skills)
- Tags: how-to-guide
- Published: 2026-04-26

---

**MCP servers implement pagination by accepting `limit` and `offset` parameters in tool input schemas and returning metadata including `total`, `count`, `has_more`, and `next_offset` to enable LLMs to navigate large result sets.**

Model Context Protocol (MCP) servers frequently expose list-type tools that return large datasets from APIs, databases, or file systems. Without pagination, these responses can exceed context limits and overwhelm the LLM. This guide demonstrates exactly how to implement pagination in MCP servers using production-ready patterns from the ComposioHQ/awesome-codex-skills repository, with complete implementations for both Python and TypeScript environments.

## Why Pagination Matters in MCP Tools

List-type MCP tools often query backends containing hundreds or thousands of records. Returning unbounded results risks token limit errors and degrades model performance. By defining a strict pagination contract, you enable the LLM to request specific data windows and traverse result sets incrementally using predictable parameters.

The repository’s [`mcp-builder/reference/mcp_best_practices.md`](https://github.com/ComposioHQ/awesome-codex-skills/blob/main/mcp-builder/reference/mcp_best_practices.md) establishes that pagination should be transport-agnostic, working identically across stdio, SSE, and HTTP transports while exposing clear continuation signals to the model.

## The Six-Step Pagination Pattern

Effective MCP pagination follows a consistent architectural pattern regardless of language. This pattern is fully implemented in both the Python and Node reference guides.

### Input Model Parameters

Define a Pydantic model (Python) or Zod schema (TypeScript) that accepts:
- **limit**: Maximum items to return (constrained between 1 and 100)
- **offset**: Number of results to skip for traditional pagination
- **response_format**: Either `"markdown"` for human readability or `"json"` for programmatic processing

In [`mcp-builder/reference/python_mcp_server.md`](https://github.com/ComposioHQ/awesome-codex-skills/blob/main/mcp-builder/reference/python_mcp_server.md), the `ListInput` BaseModel defines these fields at lines 88-90, while [`mcp-builder/reference/node_mcp_server.md`](https://github.com/ComposioHQ/awesome-codex-skills/blob/main/mcp-builder/reference/node_mcp_server.md) implements the equivalent Zod schema at lines 12-22.

### Tool Registration

Register the tool with descriptive annotations including `readOnlyHint: true` and `idempotentHint: true` to signal safe, repeatable operations. Attach the input schema to the tool definition so the MCP client exposes these fields to the LLM during tool discovery.

### API Forwarding

Forward the validated `limit` and `offset` values directly to your underlying service or database query. Maintain the same parameter names to ensure transparency.

### Response Assembly

Construct a response object containing:
- **total**: Total number of matching items in the backend
- **count**: Number of items in the current page
- **offset**: Current offset position
- **has_more**: Boolean indicating if additional pages exist
- **next_offset**: Calculated position for the subsequent request
- **items**: The actual data array

### Format Rendering

Honor the `response_format` parameter. Return JSON stringified content when `"json"` is requested, or formatted Markdown with headers and bullet lists when `"markdown"` is specified.

### Error Handling

Surface rate-limit errors or empty results with consistent messaging, allowing the LLM to adjust parameters or terminate the operation gracefully.

## Python Implementation with FastMCP

The Python reference implementation in [`mcp-builder/reference/python_mcp_server.md`](https://github.com/ComposioHQ/awesome-codex-skills/blob/main/mcp-builder/reference/python_mcp_server.md) uses the `FastMCP` class and Pydantic for input validation.

```python
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any

mcp = FastMCP("example_mcp")

class ListInput(BaseModel):
    """Pagination input for list-type tools."""
    limit: Optional[int] = Field(
        default=20,
        description="Maximum results to return",
        ge=1,
        le=100,
    )
    offset: Optional[int] = Field(
        default=0,
        description="Number of results to skip for pagination",
        ge=0,
    )
    response_format: str = Field(
        default="markdown",
        description="Either 'markdown' (human) or 'json' (machine)",
    )
    model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True, extra="forbid")

@mcp.tool(name="example_list_items", annotations={"title": "List Example Items"})
async def list_items(params: ListInput) -> str:
    # 1️⃣ Forward pagination params to the real API

    data = await api_request(limit=params.limit, offset=params.offset)

    # 2️⃣ Build a unified pagination response

    response = {
        "total": data["total"],                     # total matches in the backend

        "count": len(data["items"]),                # items in this page

        "offset": params.offset,
        "items": data["items"],
    }

    # 3️⃣ Add helper flags for the client

    if data["total"] > params.offset + len(data["items"]):
        response["has_more"] = True
        response["next_offset"] = params.offset + len(data["items"])

    # 4️⃣ Return in the requested format

    if params.response_format == "json":
        return {"content": [{"type": "text", "text": json.dumps(response, indent=2)}]}
    else:   # markdown

        md = (
            f"# Results (offset {params.offset})\n"

            f"Total: {response['total']}  \n"
            f"Showing {response['count']} items\n\n"
        )
        for item in response["items"]:
            md += f"- **{item['name']}** ({item['id']})\n"
        return {"content": [{"type": "text", "text": md}]}

```

The `ListInput` model enforces constraints using `ge=0` and `le=100` to prevent unreasonable values, while `ConfigDict` with `extra="forbid"` prevents the LLM from injecting unexpected parameters.

## TypeScript Implementation with MCP SDK

The Node.js implementation in [`mcp-builder/reference/node_mcp_server.md`](https://github.com/ComposioHQ/awesome-codex-skills/blob/main/mcp-builder/reference/node_mcp_server.md) uses the MCP SDK `Server` class and Zod for runtime validation.

```typescript
import { Server } from "mcp-sdk";
import { z } from "zod";

enum ResponseFormat {
  MARKDOWN = "markdown",
  JSON = "json",
}

/* 1️⃣ Pagination schema */
const ListInputSchema = z.object({
  limit: z
    .number()
    .int()
    .min(1)
    .max(100)
    .default(20)
    .describe("Maximum results to return"),
  offset: z
    .number()
    .int()
    .min(0)
    .default(0)
    .describe("Number of results to skip for pagination"),
  response_format: z
    .nativeEnum(ResponseFormat)
    .default(ResponseFormat.MARKDOWN)
    .describe(
      "Output format: 'markdown' for human-readable or 'json' for machine-readable"
    ),
}).strict();

type ListInput = z.infer<typeof ListInputSchema>;

const server = new Server("example_mcp");

server.registerTool(
  "example_search_items",
  {
    title: "Search Example Items",
    description: `Search items with pagination support.`,
    inputSchema: ListInputSchema,
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: true,
    },
  },
  async (params: ListInput) => {
    // 2️⃣ Call the underlying API using the validated pagination params
    const data = await makeApiRequest<any>("items/search", "GET", undefined, {
      limit: params.limit,
      offset: params.offset,
    });

    const items = data.items || [];
    const total = data.total || 0;

    // 3️⃣ Assemble response metadata
    const response: any = {
      total,
      count: items.length,
      offset: params.offset,
      items,
    };
    if (total > params.offset + items.length) {
      response.has_more = true;
      response.next_offset = params.offset + items.length;
    }

    // 4️⃣ Render according to format requested
    if (params.response_format === ResponseFormat.JSON) {
      return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] };
    }

    // Markdown rendering
    const lines = [
      `# Search Results (offset ${params.offset})`,

      `Total matches: ${total}`,
      `Showing ${items.length} items`,
      "",
    ];
    for (const it of items) {
      lines.push(`- **${it.name}** (${it.id})`);
    }
    return { content: [{ type: "text", text: lines.join("\n") }] };
  }
);

```

The Zod `.strict()` modifier ensures no additional properties pass validation, mirroring the Pydantic `extra="forbid"` behavior in the Python implementation.

## Summary

- **Use validated input models** with `limit` (1-100) and `offset` parameters to control page size and position
- **Return complete metadata** including `total`, `count`, `has_more`, and `next_offset` so the LLM can determine if and how to request subsequent pages
- **Support dual formats** by accepting a `response_format` parameter that switches between structured JSON and human-readable Markdown
- **Leverage strict validation** using Pydantic’s `ConfigDict` or Zod’s `.strict()` to prevent parameter injection
- **Annotate tools appropriately** with `readOnlyHint` and `idempotentHint` to signal safe, repeatable operations to the MCP client

## Frequently Asked Questions

### What parameters should I include for MCP pagination tools?

Every paginated MCP tool should accept `limit` (integer, max 100), `offset` (integer, starting at 0), and optionally `response_format` to toggle between JSON and Markdown output. These parameters become part of the tool's JSON Schema, allowing the LLM to discover and use them automatically during tool selection.

### How does the LLM know when more pages are available?

Include a boolean `has_more` field and a `next_offset` value in your response metadata. When `has_more` is true, the LLM can increment the offset by the current page size to request the next window. The explicit `next_offset` value removes calculation burden from the model and reduces error rates.

### Should I use offset-based or cursor-based pagination for MCP servers?

Offset-based pagination works best for stable, ordered datasets where the total count matters and skipping pages is acceptable. Cursor-based pagination (using opaque `next_cursor` tokens) is preferable for high-velocity datasets where items shift between requests. The ComposioHQ examples use offset-based pagination for simplicity, but the same metadata pattern applies to cursor implementations.

### What response format should MCP pagination tools return?

Accept a `response_format` parameter defaulting to `"markdown"` for human readability, but always support `"json"` for programmatic consumers. Markdown should include headers showing the current offset and total count, while JSON should return the raw response object with pagination metadata intact. This dual-format approach accommodates both interactive LLM sessions and automated integrations.