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) andoffsetparameters to control page size and position - Return complete metadata including
total,count,has_more, andnext_offsetso the LLM can determine if and how to request subsequent pages - Support dual formats by accepting a
response_formatparameter that switches between structured JSON and human-readable Markdown - Leverage strict validation using Pydantic’s
ConfigDictor Zod’s.strict()to prevent parameter injection - Annotate tools appropriately with
readOnlyHintandidempotentHintto 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →