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

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 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, the ListInput BaseModel defines these fields at lines 88-90, while 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 uses the FastMCP class and Pydantic for input validation.

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 uses the MCP SDK Server class and Zod for runtime validation.

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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →